Skip to main content
Rate limiting is an important mechanism for controlling resource utilization and maintaining quality of service. Go elegantly supports rate limiting with goroutines, channels, and tickers.

Basic Rate Limiting

package main

import (
	"fmt"
	"time"
)

func main() {
	// Create requests to rate limit
	requests := make(chan int, 5)
	for i := 1; i <= 5; i++ {
		requests <- i
	}
	close(requests)

	// Rate limiter: 1 request every 200ms
	limiter := time.Tick(200 * time.Millisecond)

	// Process requests at limited rate
	for req := range requests {
		<-limiter  // Block until next tick
		fmt.Println("request", req, time.Now())
	}
}
time.Tick() creates a channel that receives a value every 200ms, effectively limiting request processing to 5 per second.

Bursty Rate Limiting

Allow short bursts while maintaining overall rate limit:
func main() {
	// Bursty limiter with capacity for 3 immediate requests
	burstyLimiter := make(chan time.Time, 3)

	// Fill the bucket
	for range 3 {
		burstyLimiter <- time.Now()
	}

	// Refill at 200ms intervals
	go func() {
		for t := range time.Tick(200 * time.Millisecond) {
			burstyLimiter <- t
		}
	}()

	// Simulate 5 requests
	burstyRequests := make(chan int, 5)
	for i := 1; i <= 5; i++ {
		burstyRequests <- i
	}
	close(burstyRequests)

	// First 3 requests processed immediately (burst)
	// Remaining requests rate-limited
	for req := range burstyRequests {
		<-burstyLimiter
		fmt.Println("request", req, time.Now())
	}
}
Buffered channels make excellent token buckets for bursty rate limiting. The buffer size determines the burst capacity.

Rate Limiting Patterns

1. Token Bucket

type TokenBucket struct {
	tokens   chan struct{}
	rate     time.Duration
	capacity int
	ticker   *time.Ticker
}

func NewTokenBucket(rate time.Duration, capacity int) *TokenBucket {
	tb := &TokenBucket{
		tokens:   make(chan struct{}, capacity),
		rate:     rate,
		capacity: capacity,
		ticker:   time.NewTicker(rate),
	}
	
	// Fill initial tokens
	for i := 0; i < capacity; i++ {
		tb.tokens <- struct{}{}
	}
	
	// Refill tokens
	go func() {
		for range tb.ticker.C {
			select {
			case tb.tokens <- struct{}{}:
			default:
				// Bucket full
			}
		}
	}()
	
	return tb
}

func (tb *TokenBucket) Allow() bool {
	select {
	case <-tb.tokens:
		return true
	default:
		return false
	}
}

func (tb *TokenBucket) Wait() {
	<-tb.tokens
}

func (tb *TokenBucket) Stop() {
	tb.ticker.Stop()
}

2. Leaky Bucket

type LeakyBucket struct {
	queue    chan Request
	rate     time.Duration
	capacity int
}

func NewLeakyBucket(rate time.Duration, capacity int) *LeakyBucket {
	lb := &LeakyBucket{
		queue:    make(chan Request, capacity),
		rate:     rate,
		capacity: capacity,
	}
	
	go lb.leak()
	return lb
}

func (lb *LeakyBucket) leak() {
	ticker := time.NewTicker(lb.rate)
	defer ticker.Stop()
	
	for range ticker.C {
		select {
		case req := <-lb.queue:
			req.Process()
		default:
			// Queue empty
		}
	}
}

func (lb *LeakyBucket) Add(req Request) bool {
	select {
	case lb.queue <- req:
		return true
	default:
		return false  // Queue full
	}
}

3. Sliding Window

type SlidingWindow struct {
	mu       sync.Mutex
	requests []time.Time
	window   time.Duration
	limit    int
}

func NewSlidingWindow(window time.Duration, limit int) *SlidingWindow {
	return &SlidingWindow{
		requests: make([]time.Time, 0, limit),
		window:   window,
		limit:    limit,
	}
}

func (sw *SlidingWindow) Allow() bool {
	sw.mu.Lock()
	defer sw.mu.Unlock()
	
	now := time.Now()
	cutoff := now.Add(-sw.window)
	
	// Remove old requests
	var valid []time.Time
	for _, t := range sw.requests {
		if t.After(cutoff) {
			valid = append(valid, t)
		}
	}
	sw.requests = valid
	
	// Check limit
	if len(sw.requests) < sw.limit {
		sw.requests = append(sw.requests, now)
		return true
	}
	
	return false
}

Practical Examples

API Rate Limiter

type APIRateLimiter struct {
	limiters map[string]*TokenBucket
	mu       sync.RWMutex
	rate     time.Duration
	burst    int
}

func NewAPIRateLimiter(rate time.Duration, burst int) *APIRateLimiter {
	return &APIRateLimiter{
		limiters: make(map[string]*TokenBucket),
		rate:     rate,
		burst:    burst,
	}
}

func (arl *APIRateLimiter) GetLimiter(clientID string) *TokenBucket {
	arl.mu.Lock()
	defer arl.mu.Unlock()
	
	limiter, exists := arl.limiters[clientID]
	if !exists {
		limiter = NewTokenBucket(arl.rate, arl.burst)
		arl.limiters[clientID] = limiter
	}
	
	return limiter
}

func (arl *APIRateLimiter) RateLimitMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		clientID := r.Header.Get("X-Client-ID")
		limiter := arl.GetLimiter(clientID)
		
		if !limiter.Allow() {
			http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
			return
		}
		
		next.ServeHTTP(w, r)
	})
}

Database Connection Rate Limiter

type DBRateLimiter struct {
	semaphore chan struct{}
	ticker    *time.Ticker
}

func NewDBRateLimiter(maxConcurrent int, queryRate time.Duration) *DBRateLimiter {
	rl := &DBRateLimiter{
		semaphore: make(chan struct{}, maxConcurrent),
		ticker:    time.NewTicker(queryRate),
	}
	
	// Fill semaphore
	for i := 0; i < maxConcurrent; i++ {
		rl.semaphore <- struct{}{}
	}
	
	return rl
}

func (rl *DBRateLimiter) Query(db *sql.DB, query string) (*sql.Rows, error) {
	// Wait for rate limiter
	<-rl.ticker.C
	
	// Acquire semaphore
	<-rl.semaphore
	defer func() { rl.semaphore <- struct{}{} }()
	
	return db.Query(query)
}

Worker Pool with Rate Limiting

func rateLimitedWorkerPool(jobs []Job, workersPerSecond int) {
	jobChan := make(chan Job, len(jobs))
	rate := time.Second / time.Duration(workersPerSecond)
	ticker := time.NewTicker(rate)
	defer ticker.Stop()
	
	// Send jobs
	for _, job := range jobs {
		jobChan <- job
	}
	close(jobChan)
	
	// Process at limited rate
	for job := range jobChan {
		<-ticker.C  // Wait for next tick
		go job.Process()
	}
}

HTTP Client with Rate Limiting

type RateLimitedClient struct {
	client  *http.Client
	limiter *TokenBucket
}

func NewRateLimitedClient(requestsPerSecond int) *RateLimitedClient {
	return &RateLimitedClient{
		client:  &http.Client{Timeout: 10 * time.Second},
		limiter: NewTokenBucket(time.Second/time.Duration(requestsPerSecond), requestsPerSecond),
	}
}

func (rlc *RateLimitedClient) Get(url string) (*http.Response, error) {
	rlc.limiter.Wait()  // Block until token available
	return rlc.client.Get(url)
}

func (rlc *RateLimitedClient) TryGet(url string) (*http.Response, error) {
	if !rlc.limiter.Allow() {
		return nil, errors.New("rate limit exceeded")
	}
	return rlc.client.Get(url)
}

Advanced Patterns

Per-User Rate Limiting

type UserRateLimiter struct {
	limiters sync.Map  // map[userID]*TokenBucket
	rate     time.Duration
	burst    int
}

func (url *UserRateLimiter) AllowUser(userID string) bool {
	limiter, _ := url.limiters.LoadOrStore(userID, 
		NewTokenBucket(url.rate, url.burst))
	return limiter.(*TokenBucket).Allow()
}

Distributed Rate Limiting (Redis)

type RedisRateLimiter struct {
	client *redis.Client
	window time.Duration
	limit  int
}

func (rrl *RedisRateLimiter) Allow(key string) (bool, error) {
	now := time.Now().Unix()
	pipe := rrl.client.Pipeline()
	
	// Remove old entries
	pipe.ZRemRangeByScore(context.Background(), key, "0", 
		fmt.Sprintf("%d", now-int64(rrl.window.Seconds())))
	
	// Count current requests
	countCmd := pipe.ZCard(context.Background(), key)
	
	// Add current request
	pipe.ZAdd(context.Background(), key, redis.Z{
		Score:  float64(now),
		Member: fmt.Sprintf("%d", now),
	})
	
	// Set expiry
	pipe.Expire(context.Background(), key, rrl.window)
	
	_, err := pipe.Exec(context.Background())
	if err != nil {
		return false, err
	}
	
	return countCmd.Val() < int64(rrl.limit), nil
}

Rate Limiting Strategies

StrategyUse CaseProsCons
Token BucketAPI rate limitingAllows burstsMemory per client
Leaky BucketSmooth trafficConsistent rateMay drop requests
Fixed WindowSimple limitsEasy to implementBurst at boundaries
Sliding WindowPrecise limitingNo boundary burstsMore memory

Testing Rate Limiters

func TestRateLimiter(t *testing.T) {
	limiter := NewTokenBucket(100*time.Millisecond, 5)
	defer limiter.Stop()
	
	// Should allow burst of 5
	for i := 0; i < 5; i++ {
		if !limiter.Allow() {
			t.Errorf("Request %d should be allowed", i)
		}
	}
	
	// 6th request should be denied
	if limiter.Allow() {
		t.Error("6th request should be denied")
	}
	
	// After 100ms, should allow 1 more
	time.Sleep(100 * time.Millisecond)
	if !limiter.Allow() {
		t.Error("Request after refill should be allowed")
	}
}

Common Pitfalls

Using time.Tick() Without Stopping

// BAD: Ticker leaks
func badRateLimiter() {
	for req := range requests {
		<-time.Tick(100 * time.Millisecond)  // Creates new ticker each time!
		process(req)
	}
}

// GOOD: Reuse ticker
func goodRateLimiter() {
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()
	
	for req := range requests {
		<-ticker.C
		process(req)
	}
}

Not Handling Bursts

// BAD: Rejects legitimate bursts
if requestCount > limit {
	return ErrRateLimitExceeded
}

// GOOD: Allow bursts with token bucket
if !limiter.Allow() {
	return ErrRateLimitExceeded
}

Race Conditions in Counters

// BAD: Race condition
var count int
if count < limit {
	count++  // Race!
	return true
}

// GOOD: Use atomic or mutex
var count atomic.Int32
if count.Add(1) <= int32(limit) {
	return true
}

Best Practices

  1. Use token buckets for APIs - Allows reasonable bursts
  2. Implement per-user limits - Prevent one user from affecting others
  3. Return proper HTTP codes - Use 429 Too Many Requests
  4. Include retry information - Add Retry-After header
  5. Monitor rate limit hits - Track when limits are reached
  6. Test under load - Ensure limiter performs at scale
  7. Clean up old limiters - Remove inactive user limiters
  8. Use distributed limiting for scale - Redis/memcached for multi-server

Performance Considerations

Channel-based rate limiters are very efficient. A token bucket can handle millions of requests per second with minimal overhead.

Benchmarks (approximate)

  • Token bucket Allow(): ~50-100 ns
  • Sliding window Allow(): ~500-1000 ns
  • Redis-based Allow(): ~1-5 ms (network)

Build docs developers (and LLMs) love