MFS· Fullstack Engineer
Back to blog
gobackendredis

Rate Limiting in Go: A Practical Guide

How to implement fixed window, sliding window, and token bucket rate limiting in Go — with Redis for distributed systems.

Rate limiting is one of those things that sounds simple but has surprising depth once you start thinking about distributed systems. This post walks through three algorithms and when to use each.

Why rate limiting matters

Without rate limiting, a single misbehaving client — or an attacker — can bring down your API. Even with well-intentioned clients, thundering herd problems happen.

Rate limiting is not just a security measure. It's also about fairness and cost control in multi-tenant systems.

The three algorithms

1. Fixed Window

The simplest approach. Count requests in a fixed time window (e.g., 100 requests per minute). Reset the counter at the window boundary.

type FixedWindow struct {
    limit    int
    window   time.Duration
    mu       sync.Mutex
    count    int
    resetAt  time.Time
}

func (fw *FixedWindow) Allow() bool {
    fw.mu.Lock()
    defer fw.mu.Unlock()

    now := time.Now()
    if now.After(fw.resetAt) {
        fw.count = 0
        fw.resetAt = now.Add(fw.window)
    }

    if fw.count >= fw.limit {
        return false
    }
    fw.count++
    return true
}

Problem: Boundary bursting. A client can send 100 requests at 00:59 and 100 more at 01:01 — effectively 200 requests in 2 seconds.

2. Sliding Window

Tracks requests with timestamps instead of a simple counter. Much more accurate.

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

func (sw *SlidingWindow) Allow() bool {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    now := time.Now()
    cutoff := now.Add(-sw.window)

    // Remove expired entries
    valid := sw.requests[:0]
    for _, t := range sw.requests {
        if t.After(cutoff) {
            valid = append(valid, t)
        }
    }
    sw.requests = valid

    if len(sw.requests) >= sw.limit {
        return false
    }
    sw.requests = append(sw.requests, now)
    return true
}

Problem: Memory grows with traffic. For high-volume APIs, use Redis sorted sets instead of in-memory slices.

3. Token Bucket

The most flexible algorithm. Tokens refill at a constant rate; requests consume tokens.

type TokenBucket struct {
    capacity float64
    rate     float64 // tokens per second
    tokens   float64
    lastSeen time.Time
    mu       sync.Mutex
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastSeen).Seconds()
    tb.tokens = min(tb.capacity, tb.tokens+elapsed*tb.rate)
    tb.lastSeen = now

    if tb.tokens < 1 {
        return false
    }
    tb.tokens--
    return true
}

Token bucket is great for APIs where you want to allow short bursts but maintain a long-term average.

Which to use?

AlgorithmBurst HandlingMemoryAccuracyDistributed
Fixed WindowPoorO(1)LowEasy
Sliding WindowGoodO(n)HighHarder
Token BucketExcellentO(1)MediumMedium

Going distributed with Redis

For multi-instance deployments, in-memory limiters don't work. Use Redis with Lua scripts to make the check-and-increment atomic:

-- Lua script for token bucket in Redis
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call('HMGET', key, 'tokens', 'last_seen')
local tokens = tonumber(bucket[1]) or capacity
local last_seen = tonumber(bucket[2]) or now

local elapsed = now - last_seen
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens < 1 then
    return 0
end

tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_seen', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate) * 2)
return 1
💡

Always use Lua scripts for Redis rate limiting. It makes the read-modify-write atomic without needing WATCH/MULTI/EXEC transactions.

Wrapping it as middleware

func RateLimitMiddleware(limiter Limiter) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                w.Header().Set("Retry-After", "60")
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

That's the core of what I built in go-ratelimit. The library handles key-per-IP, per-user, and per-route limiting with a clean API.


Questions or corrections? Open an issue on GitHub or reach out via email.