Rate Limiter
System Design series #2. Topic skill: skills/rate-limiter/. Distributed rate limiting at scale, from algorithm to operation.
The problem
Rate limiting looks like a counter with a TTL in Redis. That covers part of it. At scale the hard questions sit outside the algorithm: where the limit is enforced (edge, gateway, service, or all), what the key is (user, token, IP, tenant, endpoint, method, region), whether the limit is hard or soft, whether to fail open or closed when the store fails, how to stop abusive bursts without hurting legitimate throughput, and how to count across many replicas without races or a central bottleneck.
Start by naming what you protect and how much imprecision you accept to protect it without hurting latency and simplicity. That answer drives almost every decision below.
Why rate limiting exists
A limiter serves five goals at once. It protects finite capacity, keeps fairness between clients, tenants, and users, and shrinks the blast radius of a client in a loop or a deploy that generates anomalous traffic. It also enforces commercial plans (free, pro, enterprise) and caps cost for services that call expensive dependencies such as LLMs, search, or third parties.
A good design is a set of overlapping limits (global, tenant, user, endpoint, IP security), never a single counter. Policy first, Redis second.
Where to enforce
| Layer | Good for | Limit type | Caveat |
|---|---|---|---|
| Edge / CDN | absorb volumetric abuse, L7 DDoS, block early | coarse | lacks rich business context |
| API Gateway | most common place; per token/user/tenant/endpoint | generic API | if it bottlenecks, the whole platform feels it |
| Inside the service | limit depends on domain context (expensive report, LLM inference) | semantic / cost | closest to the truth, furthest from the edge |
Mature architectures layer all three: edge for coarse protection, gateway for generic API limits, service for semantic and cost limits. Each layer catches what the one above cannot see, the same defense-in-depth idea as security layering.
The limit key
Choose the dimensions on purpose, often as a composite. The key sets cardinality, which is how many distinct counters exist and so drives store load and hot-key risk. It also sets the abuse surface: an IP limit is easy to evade behind NAT or proxies, while a token limit ties to identity. Composite keys (tenant plus endpoint) localize limits precisely but multiply the counter count.
Algorithms
Each algorithm is a different answer to how you count.
Fixed window counter counts requests in a fixed clock bucket (for example per minute) and resets at the boundary. It costs one counter per key. Its flaw is the boundary burst: a client sends a full window at 11:59:59 and another at 12:00:00, about 2x the intended rate.
Sliding window log stores a timestamp per request and counts those inside the trailing window. It is exact and fair, but memory and CPU grow with traffic. Fine at low volume, expensive at scale.
Sliding window counter approximates the sliding window with two fixed buckets, current and previous, weighted by the elapsed fraction of the current window. It smooths the boundary burst with one or two counters per key and a single atomic operation. It is the common production compromise between fixed-window cost and sliding-log accuracy.
estimate = current_count + previous_count * (1 - elapsed_fraction)
Leaky bucket queues requests and drains them at a constant rate, rejecting overflow. It produces a smooth, constant output rate, good for shaping traffic into a downstream that wants steady flow.
Token bucket is the industry default. A bucket holds up to capacity tokens that refill at a constant rate; each request takes one token, and an empty bucket rejects or queues. It separates the allowed burst (capacity) from the sustained rate (refill): a client spikes up to capacity at once, then is held to rate. It is the classic traffic-shaping algorithm from networking (Tanenbaum, Computer Networks) and what most API platforms and cloud providers expose.
on request:
now = clock()
tokens = min(capacity, tokens + (now - last_refill) * rate)
last_refill = now
if tokens >= 1: tokens -= 1; allow
else: reject (429, Retry-After)
Pick token bucket for a sustained rate with controlled burst, and sliding window counter for a near-exact rolling limit with a sub-ms check. Make the check atomic: a Lua script in Redis runs read, decide, and write as one server-side operation, so concurrent replicas cannot race.
State and storage
Keep each key's bucket or counter in an in-memory store (Redis) with a TTL (for example 2 windows), so cold keys expire and memory stays bounded. One Lua script does refill or weighting, check, and decrement in one round trip. Limit config (key to quota and refill) lives in a config service with a short local cache and reloads without a redeploy.
Hot keys
One tenant with disproportionate traffic concentrates load on one store shard. Four mitigations apply.
Local budgets grant each node a slice of the global budget to decrement locally, reconciling with the central store periodically. You over-admit by up to one slice per node in exchange for a large drop in central-store pressure. This is Helland's data on the outside, reconciled asynchronously, applied to counters.
Composite limits spread a tenant across sub-keys (tenant plus endpoint) so no single counter runs hot. A local pre-check throttles a saturating key at the node before it touches the shared store. Sharding the store by key with consistent hashing keeps one key's operations on one shard.
Fail-open vs fail-closed
Decide on purpose what happens when the store fails. Fail-open (allow) protects legitimate traffic from your own outage but drops protection while it lasts. Fail-closed (deny) keeps protection but turns a limiter outage into an availability incident.
Most platforms fail open on the generic path and fail closed only where the limit guards a hard capacity or cost ceiling. State the posture of each layer and why. Pair it with a circuit breaker (Nygard): once the store is unhealthy, the limiter stops calling it and applies the fallback posture at once, so a slow store does not add latency to every request.
Hard vs soft limits
A hard limit rejects with 429 Too Many Requests and Retry-After. A soft limit warns, degrades, or queues and still serves. Cost and fairness limits are often soft with escalating friction; security and capacity limits are hard.
Multi-region
Strict global accuracy needs a synchronous cross-region hop on every request, which destroys latency. The realistic compromise is regional budgets with reconciliation: each region enforces a local share of the global limit and reconciles asynchronously. A global limit of N may briefly admit a bit more than N, in exchange for low latency. Reserve strict global counting for the few limits that need it. This is the CAP/PACELC trade-off made concrete: in normal operation you trade consistency for latency.
Shadow mode
Before enforcing a hard limit, run it in observation mode. The system computes the block decision, does not apply it, and emits what it would have blocked. You see "this new limit would have 429'd 40% of tenant X's legitimate traffic" on a dashboard instead of in a 3am page. Promote a limit from shadow to enforce only after the shadow metrics look right. Of every practice on this page, it pays back the most.
Observability
Track requests allowed per policy, requests blocked (429) per policy and dimension, decision latency (the limiter must add near zero to the request path), store op latency, fail-open events, top-throttled keys, and shadow-mode would-block counts.
At 3am someone must answer which limit fired, in which dimension, whether the policy was wrong, and whether traffic regressed. A design that cannot answer that is not operationally governable, and that is the bar.
Failure modes
| Failure | Impact | Mitigation |
|---|---|---|
| Store down | no central counting | chosen posture (fail-open default) + local fallback budget + circuit breaker + alert |
| Hot key saturates a shard | shard CPU spike, latency | local pre-filter + composite keys + local budgets |
| Bad policy pushed | mass false 429s | shadow mode first; fast config rollback; per-policy block-rate alert |
| Gateway limiter becomes the bottleneck | platform-wide latency | push coarse limits to edge, keep the gateway check O(1) |
Incremental plan
| Phase | Scope |
|---|---|
| Vertical slice | One enforcement layer (gateway), fixed or sliding window, single Redis, one limit key, 429 + Retry-After, allow/deny metrics. |
| Correctness and ops | Token bucket via atomic Lua, fail-open + circuit breaker, shadow mode, hot-reload config, per-policy metrics. |
| Scale | Sharded store, hot-key local budgets + pre-filter, layered enforcement (edge, gateway, service). |
| Global | Regional budgets with reconciliation, composite policies, cost and LLM semantic limits in the service. |
Trade-offs to state
State each one in the design: accuracy vs latency (exact global count vs local budget), fail-open vs fail-closed, central store vs local budgets, one limit key vs composite, enforce now vs shadow first, and placement per layer (catch early at the edge vs rich context in the service).
Worked example
The repo ships a complete filled SDD for a distributed rate limiter as the agent's good example: sliding window counter, 200k QPS, under 1 ms added latency, fail-open. See good-system-design-example.md. The general method lives in System Design Method.
References
| Author | Work | Used for |
|---|---|---|
| Andrew Tanenbaum | Computer Networks | token bucket and leaky bucket traffic shaping |
| Martin Kleppmann | Designing Data-Intensive Applications, 2017 | consistency vs latency, accepting bounded imprecision |
| Michael Nygard | Release It!, 2nd ed., 2018 | circuit breaker, bulkhead, fail-fast as a stability decision |
| Werner Vogels / Amazon | design for failure | the store will fail |
| Pat Helland | Life Beyond Distributed Transactions, CIDR 2007 | local budgets as independent, asynchronously reconciled state |
| Eric Brewer; Daniel Abadi | CAP (PODC 2000); PACELC (2012) | latency vs consistency framing for multi-region budgets |
| Stripe Engineering | Scaling your API with rate limiters | token bucket in production practice |
| Redis docs | Rate limiting with Redis, EVAL/Lua |
atomic decisions |
This page mirrors Rate Limiter in the wiki. Edit it there.