Rate limiting that doesn't ruin your API: token bucket, sliding window, and honest 429s
Every public API needs rate limiting; most implementations punish legitimate users. The algorithms compared, where to enforce them, and how to design 429 responses clients can actually respect.
Rate limiting has two jobs: protect your infrastructure from overload, and protect fair access for everyone else when one client misbehaves. A limiter that fires on legitimate burst traffic โ a dashboard loading twelve widgets at once โ is failing at the second job. Algorithm choice is what decides that.
The algorithms, honestly compared
- Fixed window: count requests per clock window (100/minute). Trivial to implement, but allows 2x burst at window boundaries - 100 requests at 11:59:59 and 100 more at 12:00:00 are both legal.
- Sliding window log: store every request timestamp, count the last 60s exactly. Perfectly accurate, memory cost grows with traffic - fine for low-volume expensive endpoints, wrong for high-QPS APIs.
- Sliding window counter: weighted blend of current and previous fixed windows. Accurate to within a few percent with O(1) memory - the pragmatic default for most APIs.
- Token bucket: tokens refill at a steady rate, requests spend them, bucket capacity allows controlled bursts. This models what users actually do (bursts followed by quiet) - it's what we use for user-facing limits.
A token bucket in 20 lines of Redis
// Atomic token bucket via Lua - one round trip, no races
const SCRIPT = `
local tokens = tonumber(redis.call('HGET', KEYS[1], 'tokens') or ARGV[1])
local ts = tonumber(redis.call('HGET', KEYS[1], 'ts') or 0)
local now = tonumber(ARGV[3])
local refilled = math.min(tonumber(ARGV[1]), tokens + (now - ts) * tonumber(ARGV[2]))
if refilled >= 1 then
redis.call('HSET', KEYS[1], 'tokens', refilled - 1, 'ts', now)
redis.call('EXPIRE', KEYS[1], 120)
return {1, math.floor(refilled - 1)}
end
redis.call('HSET', KEYS[1], 'tokens', refilled, 'ts', now)
return {0, math.floor(refilled)}
`;
export async function allow(key: string, capacity = 20, perSecond = 5) {
const [ok, remaining] = (await redis.eval(
SCRIPT, 1, `rl:${key}`, capacity, perSecond, Date.now() / 1000,
)) as [number, number];
return { allowed: ok === 1, remaining };
}The Lua script matters: a read-modify-write from application code has a race window under concurrency, and rate limiters are precisely the code path that gets hit concurrently. If you're on Cloudflare Workers or similar, Durable Objects give you the same single-writer guarantee without Redis.
Design the 429, don't just throw it
HTTP/1.1 429 Too Many Requests
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 12
{ "error": "rate_limited", "retry_after_seconds": 12 }Send RateLimit-* headers on every response, not just the 429 โ well-behaved clients throttle themselves before hitting the wall. And send Retry-After with a real number; 'try again later' with no timing information guarantees the client retries immediately, which is the opposite of what you wanted.
Where to enforce
- Edge/gateway limits (per-IP, generous): shed abusive traffic before it touches your origin. This is DDoS hygiene, not fairness.
- Application limits (per-API-key or per-user): the fairness layer, where token bucket parameters map to your pricing tiers.
- Expensive-endpoint limits (per-operation): exports, searches, AI calls - one global limit either strangles cheap endpoints or fails to protect expensive ones.
- Never rate limit by IP alone for authenticated APIs - corporate NAT puts a thousand legitimate users behind one address.
The takeaway
Token bucket for user-facing fairness, sliding window counter for infrastructure protection, atomic storage for correctness, and honest headers so clients can cooperate. Rate limiting done well is invisible to legitimate users โ that invisibility is the success metric.
Written by Appesto Engineering.