Design a rate limiter
Switch reference
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
Design a service that limits how many requests each API client can make. It sits in front of every API endpoint at high traffic. Remix: limits must hold across three regions.
Requirements
Functional requirements
- Limit each client (API key, user, or IP) to N requests per window, per endpoint or tier.
- Return
429 Too Many RequestswithRetry-AfterandX-RateLimit-Remainingheaders. - Rules change without a deploy (per-customer overrides for enterprise plans).
Non-functional requirements
- Adds under 2 ms p99 to every request; the limiter sits on the hot path.
- Works across many gateway instances, so counts must be shared.
- Fails open: if the limiter store is down, serve traffic rather than reject it.
- Roughly accurate is fine; a few percent over the limit at the edges is acceptable.
Back-of-the-envelope estimation
- 1M requests/s at peak across 200 gateway pods, 10M active keys.
- One counter per (key, rule, window) ≈ 50 bytes → 10M × 3 rules × 50 B ≈ 1.5 GB in memory. Fits in a small Redis cluster.
API design
Internal, called by the gateway: check(key, rule) → {allowed, remaining, reset_at}. Rules are config: {match: "POST /v1/charges", tier: "pro", limit: 100, window: "1s"}.
Data model
Redis keys rl:{rule}:{key}:{window_start} holding a count with a TTL of two windows. Rules live in a config store (e.g. a table plus a pushed cache in each gateway).
High-level design
flowchart LR
C[Client] --> G[API gateway]
G -->|check + incr| R[(Redis cluster)]
G --> S[Service]
CFG[Rules store] -. push .-> G
- The gateway matches the request to rules from its in-memory rule cache.
- It runs one Redis Lua script per rule that increments and reads in one round trip.
- Allowed → forward; denied → 429 with headers.
Deep dives
Algorithm choice.
| Algorithm | Accuracy | Memory | Notes |
|---|---|---|---|
| Fixed window | Bursts of 2× at edges | 1 counter | Simplest |
| Sliding log | Exact | 1 entry per request | Too much memory at 1M rps |
| Sliding window counter | ~exact | 2 counters | Pick this: weight the previous window by overlap |
| Token bucket | Smooth, allows bursts | tokens + timestamp | Good for "burst of 20, then 5/s" APIs |
Sliding window counter: estimate = prev_count × (1 − elapsed/window) + curr_count.
Atomicity. Do the read-increment-compare in a Lua script so two gateways can't both take the last slot.
Hot keys. One huge customer can pin a Redis shard. Shard by key hash, and for the top keys keep a local token bucket per gateway with a share of the global limit (limit ÷ pods), synced every 100 ms.
Failure. Timeout of 5 ms on the Redis call; on timeout, allow and emit a metric. A circuit breaker stops calling Redis when it's down.
Multi-region. Keep limits per region (simplest), or divide the global limit across regions by traffic share and rebalance every few seconds.
Trade-offs
- Local-only counters are fastest but inaccurate with many pods; central Redis is accurate but adds a hop. The hybrid (local for hot keys, Redis otherwise) gets both.
- Fail open protects availability at the cost of letting abuse through briefly; for login endpoints, fail closed instead.