Rate Limiting with Redis
A rate limiter has to answer two questions: which algorithm best fits your traffic patterns, and where the counters live. The answers depend on how much traffic you’re handling and whether multiple services need to share state.
Naive approach
Start with a fixed window algorithm and an in-memory map from IP address to request count in the current window. For most low-traffic applications this is fine and you can stop here.
counts := map[netip.Addr]int{} // requests in the current window
It has two known issues:
- Edge bursts: with a limit of
100 req/minute, a client can make 100 requests at 11:59:59 and another 100 at 12:00:01, getting 200 through in two seconds. - Memory growth: the map holds on to clients it hasn’t seen in a long time unless you prune it.
Smoothing the curve
A sliding window log stores a timestamp for each request and only counts the ones inside a rolling window, so there’s no boundary left to burst across.
The cost is memory. A fixed window keeps one counter per client, but a log
keeps a timestamp for every request it allowed in the window. With a limit
of 100 req/minute, a busy client holds up to 100 entries instead of one,
and 10,000 busy clients add up to a million. For services where bursts are
the actual concern, it’s worth it.
Offloading to Redis
Once the limiter outgrows a single process, the counters and the decision logic both need to move out of the application. Redis can handle both: a sorted set holds each client’s timestamps, and a Lua script runs the limiter atomically against it.
Here’s an implementation using Go:
package ratelimit
import (
"context"
"fmt"
"math/rand"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
type Limiter struct {
client *redis.Client
burst int
window time.Duration
}
// Atomic sliding window log. Redis supplies the clock so app servers agree on it.
const limiterScript = `
local key = KEYS[1]
local window_ms = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local t = redis.call('TIME')
local now = t[1] * 1000 + math.floor(t[2] / 1000)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window_ms)
if redis.call('ZCARD', key) >= limit then
return 0
end
redis.call('ZADD', key, now, ARGV[3])
redis.call('PEXPIRE', key, window_ms)
return 1
`
// NewLimiter allows up to burst requests in any window of
// burst/requestsPerSecond seconds.
func NewLimiter(redisAddr string, requestsPerSecond float64, burst int) *Limiter {
return &Limiter{
client: redis.NewClient(&redis.Options{
Addr: redisAddr,
}),
burst: burst,
window: time.Duration(float64(burst) / requestsPerSecond * float64(time.Second)),
}
}
// Allow checks if a request from the given identifier should be allowed
func (l *Limiter) Allow(ctx context.Context, identifier string) (bool, error) {
// Sorted set members must be unique, or same-millisecond requests collapse
member := strconv.FormatInt(rand.Int63(), 36)
allowed, err := l.client.Eval(ctx, limiterScript, []string{identifier},
l.window.Milliseconds(),
l.burst,
member,
).Bool()
if err != nil {
return false, fmt.Errorf("rate limiter error: %w", err)
}
return allowed, nil
}
The window is burst / requestsPerSecond, so requestsPerSecond=10 and
burst=50 give a five-second window. A client can spend all 50 requests at
once, but over time it averages out to 10 per second.
Each allowed request resets the key’s TTL to the window length, so idle clients expire on their own and Redis memory stays bounded even across millions of identifiers.
Keeping state in Redis lets the application servers stay stateless, so you can add or remove them without worrying about which one a client last hit. Each decision costs a single sub-millisecond Redis round trip, a negligible slice of the total request latency.