Design a Distributed Rate Limiter
Design a service that limits how many requests a client can make in a time window (e.g., 100 requests per minute per user) across a fleet of many API servers. The core challenges are choosing an algorithm with the right burst and accuracy tradeoffs, sharing counter state across servers with low latency, and deciding how the limiter should fail.
1Requirements
Functional
- • Limit requests per client key (user ID, API key, or IP) within configurable time windows.
- • Support multiple rules per route and per tier (e.g., free users 100/min, paid users 1,000/min).
- • Return HTTP 429 with headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) when a client is throttled.
- • Enforce limits consistently across all API servers, not per server.
- • Allow operators to update rules dynamically without redeploying services.
Non-functional
- • Very low overhead: the limiter check must add no more than 1-2 ms to each request.
- • High availability: the limiter must not become a single point of failure for the whole API.
- • Defined failure mode: choose and document fail-open (allow traffic) vs fail-closed (reject traffic) when the limiter is unreachable.
- • Accuracy: small transient over-admission is acceptable; large systematic over-admission is not.
- • Memory efficiency: support tens of millions of active client keys with bounded memory.
2Back-of-envelope estimation
| Request volume | 10M DAU x 50 requests/day = 500M req/day ≈ 5,800 QPS, peak 3x ≈ 17K QPS | Every request performs at least one limiter check |
| Active keys | 10M users x 3 rules (per-route, per-user, per-IP) = 30M counters | |
| Memory per counter | Token bucket state ≈ key (40 B) + tokens (8 B) + last_refill (8 B) + overhead ≈ 100 B | 30M x 100 B ≈ 3 GB, fits in one Redis cluster easily |
| Redis load | 17K QPS x 1 Lua script call each ≈ 17K Redis ops/sec | A single Redis node handles ~100K ops/sec; shard by key for headroom |
| Added latency | Same-AZ Redis round trip ≈ 0.5-1 ms | Acceptable; cross-region calls (30-100 ms) are not, so keep limiter state regional |
3API design
POST /api/ratelimit/checkInternal call from API gateway or middleware. Body: { key, rule }. Returns { allowed: boolean, remaining, retryAfterMs }. Usually implemented as a library plus Redis rather than an HTTP hop.
PUT /api/ratelimit/rules/{ruleId}Operator endpoint to create or update a rule: { routePattern, keyType, limit, windowSeconds, tierOverrides }. Rules propagate to workers via config push or a watched store.
GET /api/ratelimit/rulesList all active rules for auditing and debugging.
GET /api/ratelimit/usage/{key}Inspect current counter state for a client key, used by support and abuse teams.
4High-level design
Place the rate limiter as middleware in the API gateway, in front of all application services. Every incoming request is mapped to one or more limit keys (user:123:route:/search, ip:1.2.3.4) and each key is checked against its rule before the request is forwarded. Rejected requests get a 429 with Retry-After, so clients can back off instead of hammering.
Counter state cannot live in per-server memory because a load balancer spreads one client across many servers; each server would enforce N times the intended limit. So counters live in a shared, fast store: Redis, sharded by limit key using consistent hashing. Each check is a single Lua script executed atomically on the shard owning that key, which reads the counter, applies the algorithm, and returns allow or deny in one round trip.
Rules live in a configuration store (e.g., a small database or etcd) and are cached in each gateway worker's memory, refreshed on change notification. This keeps the hot path free of rule lookups: the only network call per request is the one Redis operation.
For resilience, each gateway also keeps a small local fallback limiter (an in-memory token bucket with a generous limit). If Redis times out, the gateway applies the local limiter and allows the request (fail-open), emitting metrics so operators see degraded accuracy. Throttling events are logged asynchronously to a queue for abuse analytics and alerting.
5Data model
rules
rule_id BIGINT PK, route_pattern VARCHAR(255), key_type VARCHAR(20), limit_count INT, window_seconds INT, tier VARCHAR(20), updated_at TIMESTAMPSmall table, cached in every gateway worker
counters (Redis)
key STRING (e.g. rl:user:123:search), tokens FLOAT, last_refill_ms BIGINT, TTL = window x 2Token bucket state; TTL evicts idle keys so memory stays bounded
throttle_events
event_id UUID PK, key VARCHAR(128), rule_id BIGINT, ts TIMESTAMP, server_id VARCHAR(64)Written asynchronously for abuse detection and dashboards
6Deep dives
Algorithm choice: token bucket vs windows
Token bucket gives each key a bucket of capacity B that refills at rate R per second. A request consumes one token; if the bucket is empty the request is rejected. It allows controlled bursts up to B while enforcing a long-run average of R, uses constant memory per key (two numbers), and refill can be computed lazily from the timestamp, so no background process is needed. This is the default answer for API rate limiting.
Fixed window counters (increment a counter per key per minute) are the simplest but suffer the boundary problem: a client can send the full limit at 0:59 and again at 1:01, achieving 2x the limit across the boundary. Sliding window log stores a timestamp per request and is perfectly accurate, but memory grows with the request rate, which is unacceptable for high-volume keys.
Sliding window counter is the practical compromise: keep the current and previous fixed-window counts and estimate the sliding count as current + previous x overlap fraction. It smooths the boundary problem with constant memory. Cloudflare famously runs this and reported that the approximation misjudges only a tiny fraction of requests in practice.
Race conditions and atomicity in a shared store
A naive GET, compute, SET sequence against Redis is racy: two gateway servers can read the same counter value concurrently and both admit a request that should have been the last one. Under high concurrency this systematically over-admits.
The fix is to make the read-modify-write atomic on the Redis server. A Lua script (or a MULTI/EXEC transaction) that refills the bucket, checks tokens, decrements, and returns the verdict executes as one atomic unit per key. Since all state for one key lives on one shard, no cross-shard coordination is needed, and the algorithm remains one round trip per check.
An alternative for extreme throughput is local batching: each gateway leases a quota slice (say 10% of a key's limit) from the central store and enforces it locally, re-leasing as it runs out. This cuts Redis traffic by an order of magnitude at the cost of some accuracy when traffic is unevenly spread across gateways.
Failure modes: fail-open vs fail-closed
When Redis is slow or down, the limiter must decide instantly. Fail-open (allow all traffic) preserves availability for legitimate users but leaves the backend unprotected exactly when an attack might be the cause of the failure. Fail-closed (reject everything) protects the backend but turns a limiter outage into a full API outage.
The usual production stance: fail-open for user-facing product APIs, because availability is the point, but pair it with per-gateway local fallback limits so a runaway client is still capped. For security-sensitive endpoints such as login and OTP verification, fail-closed is often correct, because unthrottled credential stuffing is worse than a temporary login outage.
Whichever you pick, use tight timeouts (a few ms) on the limiter call, circuit-break to the fallback quickly, and alarm loudly on fallback engagement so degraded enforcement never goes unnoticed.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Node.js (Express or Next.js middleware) + a single Redis instance (Upstash free tier or Docker on a $6 VPS)
- 01Run Redis locally with docker run -p 6379:6379 redis and connect with ioredis.
- 02Write the token bucket as a Lua script (refill from elapsed time, decrement, return allowed + remaining) and load it once with redis.defineCommand.
- 03Store per-key state in a Redis hash rl:{key} with fields tokens and last_refill_ms, and set PEXPIRE to 2x the refill window so idle keys evict themselves.
- 04Wrap the script call in an Express middleware that builds the key from user id (or req.ip as fallback) plus route, with a 5 ms timeout on the Redis call.
- 05On deny, respond 429 with X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers computed from the script's return values.
- 06On Redis timeout or error, fail open: allow the request, apply a small in-process fallback bucket per key, and increment a limiter_degraded metric.
- 07Keep rules in a rules.json (routePattern, limit, windowSeconds) loaded at boot and hot-reloaded on file change; match longest prefix per request.
- 08Verify atomicity by hammering one key from two processes with autocannon and asserting admitted count never exceeds the limit.
Atomic token bucket as a Redis Lua script
typescript// KEYS[1] = bucket key, ARGV = [capacity, refillPerSec, nowMs, cost]
export const TOKEN_BUCKET_LUA = [
"local capacity = tonumber(ARGV[1])",
"local rate = tonumber(ARGV[2])",
"local now = tonumber(ARGV[3])",
"local cost = tonumber(ARGV[4])",
"local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')",
"local tokens = tonumber(state[1]) or capacity",
"local ts = tonumber(state[2]) or now",
"tokens = math.min(capacity, tokens + (now - ts) / 1000 * rate)",
"local allowed = 0",
"if tokens >= cost then",
" tokens = tokens - cost",
" allowed = 1",
"end",
"redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)",
"redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 2000))",
"return { allowed, tostring(tokens) }",
].join("\n");Express middleware calling the script
typescriptredis.defineCommand("tokenBucket", { numberOfKeys: 1, lua: TOKEN_BUCKET_LUA });
export function rateLimit(limit: number, windowSeconds: number) {
const ratePerSec = limit / windowSeconds;
return async (req: any, res: any, next: any) => {
const key = "rl:" + (req.user?.id ?? req.ip) + ":" + req.path;
try {
const [allowed, tokens] = await withTimeout(
redis.tokenBucket(key, limit, ratePerSec, Date.now(), 1),
5 // ms budget; fail open past this
);
res.set("X-RateLimit-Limit", String(limit));
res.set("X-RateLimit-Remaining", String(Math.floor(Number(tokens))));
if (allowed === 1) return next();
const retryMs = Math.ceil(((1 - Number(tokens)) / ratePerSec) * 1000);
res.set("Retry-After", String(Math.ceil(retryMs / 1000)));
return res.status(429).json({ error: "rate limited" });
} catch {
metrics.increment("limiter_degraded");
return next(); // fail open, backed by a local fallback bucket
}
};
}In-process fallback bucket for Redis outages
typescripttype Bucket = { tokens: number; ts: number };
const local = new Map<string, Bucket>();
export function localAllow(key: string, capacity: number, ratePerSec: number): boolean {
const now = Date.now();
const b = local.get(key) ?? { tokens: capacity, ts: now };
b.tokens = Math.min(capacity, b.tokens + ((now - b.ts) / 1000) * ratePerSec);
b.ts = now;
if (b.tokens < 1) {
local.set(key, b);
return false;
}
b.tokens -= 1;
local.set(key, b);
if (local.size > 50_000) local.clear(); // crude memory cap for an MVP
return true;
}Bottlenecks & failure modes
- ⚠A single Redis node caps throughput and is a single point of failure; shard counters by key and run replicas with automatic failover.
- ⚠One abusive key checked at extreme rates becomes a hot shard; mitigate with local quota leasing or short-TTL local caching of deny verdicts.
- ⚠Cross-region synchronization of counters adds 30-100 ms; keep limits regional and accept that a global client gets roughly regions x limit, or route each key to a home region.
- ⚠Unbounded key cardinality (e.g., limits per IP under a spoofed-IP flood) can exhaust memory; enforce TTLs on counters and cap tracked key count.
- ⚠Placing the limiter as a separate HTTP service doubles per-request hops; prefer an in-process library talking directly to the counter store.
Key takeaways
- ▸Token bucket is the go-to algorithm: constant memory, tunable bursts, lazy refill; sliding window counter is the best window-based compromise.
- ▸Distributed enforcement requires shared state plus atomic updates; a Lua script on a key-sharded Redis is the standard pattern.
- ▸Always state the failure mode explicitly: fail-open with local fallback for product APIs, fail-closed for auth endpoints.
- ▸Return 429 with Retry-After so well-behaved clients back off; rate limiting is a contract with clients, not just a defense.
- ▸Keep the hot path to exactly one network round trip; rules and configuration belong in local caches.