30-Second Glance
- What: Distributed throttle — N requests per window per key (user, IP, API key, endpoint) — enforced at the gateway.
- Scale: ~1M checks/s across the edge; each check must add <2 ms p99 and survive Redis loss (fail-open for reads, fail-closed for writes).
- Core trick: Token bucket / sliding-window-counter evaluated atomically in Redis via Lua; rules versioned + pushed to gateways.
- Hardest part: Exactness vs cost (sliding-window-log is exact but O(n) memory); races across app hosts; clock skew; multi-DC consistency.
Interactive playground Fire bursts at a live token bucket, flip algorithms, split quotas across DCs: Open Rate Limiter simulator →
1. Requirements
Functional:
- Limit by multiple dimensions: per-user, per-IP, per-API-key, per-endpoint, global floor.
- Multiple algorithms: token bucket, leaky bucket, fixed window, sliding-window counter (at least two in prod).
- Configurable rules per tier/endpoint with runtime updates (no deploy).
- Headers on every response:
X-RateLimit-Limit/Remaining/Reset; 429 withRetry-Afteron exceed. - Audit trail of rule changes + top-offender visibility.
Non-functional:
| Requirement | Target |
|---|---|
| Added latency p99 | < 2 ms per check at edge |
| Availability | Limiter failure must not take down the product → fail-open on read path |
| Consistency | Approximate is fine (±1–2 requests at window edges acceptable); no sustained over-admission |
| Throughput | 1M+ checks/s, horizontally scalable |
Out of scope:
- Billing/metering (consumes limiter signals, separate system).
- WAF/bot detection (feeds blocklists in, separate system).
- Per-request cost weighting beyond simple integer costs (v2).
2. Capacity Estimation
Assumptions: 100 edge gateways × 10k req/s each = 1M checks/s; 10M distinct limit-keys active; rule evaluation = 1 Redis Lua call.
| Metric | Math | Result |
|---|---|---|
| Check QPS (avg / peak) | 1M avg, 3× peak | 1M / 3M |
| Redis ops | 1 Lua eval per check (+1 pipelined TTL refresh) | ~1–2M ops/s → ≥20 shards at ~100k ops/s/shard, before replicas |
| Memory (token bucket) | 10M keys × ~300 B (key + tokens + ts + TTL overhead) | ~3 GB — trivially fits; sliding-window-log would be 10M × 100 entries × 16 B ≈ 16 GB+ (why we avoid it) |
| Bandwidth | 1M × ~500 B round-trip | ~500 MB/s edge→Redis — must be same-AZ; cross-AZ doubles cost |
| Rule-push fan-out | 10k gateways × 1 KB rules × 1/min | noise —itatively negligible |
3. API Design
The limiter is enforcement middleware, not a CRUD product — but it exposes two surfaces:
# Internal check (gateway -> limiter sidecar/service, or embedded Lua call)
POST /v1/check
{ "key": "user:8123:POST:/api/v1/shorten",
"cost": 1,
"policy": { "algorithm": "token_bucket", "capacity": 100, "refill_per_sec": 10 } }
200 OK
{ "allowed": true, "remaining": 87,
"limit": 100, "reset_ms": 1300 }
429 Too Many Requests
Retry-After: 2
{ "allowed": false, "remaining": 0, "reset_ms": 2000 }| Endpoint | Purpose | Notes |
|---|---|---|
POST /v1/check |
Allow/deny + debit | Idempotent per request-ID (retries don't double-spend); cost supports weighted endpoints |
PUT /v1/rules/{scope} |
Upsert rule | Versioned (rule_version); canary % rollout; audit-logged |
GET /v1/rules/{scope} |
Fetch effective rule | Gateways long-poll/cache 30 s; version header for skew detection |
GET /v1/top-offenders?window=1m |
Ops visibility | Served from analytics rollup, not Redis |
- Response headers on every proxied response:
X-RateLimit-Limit,-Remaining,-Reset, plusX-RateLimit-Policyfor debuggability. - Quota tiers compose: request must pass global → endpoint → user/IP checks (deny on first fail, debit only on full pass — or debit-all-then-refund; pick debit-on-pass and say why: no refund races).
4. Data Model & Storage
| Entity | Store | Partition / shard key | Why this store |
|---|---|---|---|
Bucket state {tokens, last_refill_ts} / window counters |
Redis Cluster (in-memory, AOF on) | limit-key (scope:id:endpoint) |
Sub-ms atomic ops; keyspace sharded by hash; TTL auto-cleans idle keys |
Rules {scope, algorithm, params, version} |
Postgres (source of truth) + pushed to gateway memory | scope | Tiny, strongly consistent, auditable; Single Leader Replication is plenty |
| Hit/deny analytics | Kafka → ClickHouse | (scope, minute) | Top-offender + capacity planning; off the hot path |
-- Redis key layout (token bucket), all with PEXPIRE = window cap
-- ratelimit:{scope}:{id}:{endpoint} -> HASH { tokens, ts, version }
-- Fixed/sliding counters: ratelimit:{...}:{window_start} -> INT with EXPIRE- Redis persistence: AOF every second — losing 1 s of buckets on failover only over-admits slightly; acceptable per requirements. No RDB restores into prod (stale buckets under-admit).
- Gateway caches rules in memory (30 s TTL + version check) so rule reads never add a hop.
5. High-Level Design
Request path: client → L4 LB → API gateway (Envoy/nginx) → limiter check (embedded Lua over pooled Redis conn, rules from local cache) → allow: proxy to service with rate headers; deny: 429 immediately. Control path: admin → config service (Postgres) → pub/sub rule push → gateways.
- Gateway (enforcement point): runs the check inline before proxying; holds rule cache; implements fail-open/closed policy.
- Redis Cluster: one Lua eval per check — fetch bucket, refill by elapsed time, admit-or-deny, set TTL — atomically.
- Config service: rule CRUD, versioning, canary rollout, audit log.
- Analytics side-path: allow/deny counters → Kafka → ClickHouse → dashboards + auto-tune suggestions.
- Fallback: in-process leaky counter (per-gateway, approximate) when Redis is unreachable.
6. Deep Dives
6.1 Distributed token bucket on Redis (the exact mechanism)
All logic in one Lua script so fetch-refill-decide-store is atomic across 100 gateways:
tokens = min(capacity, stored + elapsed * refill_rate)
if tokens >= cost: tokens -= cost; allowed = 1
else: allowed = 0
store(tokens, now); PEXPIRE(key, ceil(capacity / refill_rate) * 2)
return {allowed, tokens}Why this shape: elapsed-time refill needs no background timers; TTL = 2× drain time bounds memory (idle keys vanish); returning remaining lets the gateway set headers without a second round-trip. Burst = capacity, sustained = refill_rate — two knobs map directly onto product tiers ("100 burst, 10/s sustained"). Clock: use Redis server TIME inside Lua, not client clocks — eliminates client-skew over-admission. Precision: milliseconds; sub-ms refill rounds down (slightly strict, safe direction).
6.2 Sliding windows: fixed vs log vs counter
| Approach | Pros | Cons | Use when |
|---|---|---|---|
| Fixed window (counter per minute) | 1 key, O(1) | 2× burst at boundaries (spike at :00) | Coarse quotas (daily API calls) |
| Sliding-window log (store each ts) | Exact | O(n) memory + trim cost; 16 GB+ at our scale | Low-rate, high-value actions (password resets, OTPs) |
| Sliding-window counter (chosen for general use) | O(1), smooth | ±small edge error | Standard per-user/per-IP throttles |
Counter formula: estimate = current_window_count + prev_window_count × overlap_fraction. Two integer keys with EXPIRE, one INCR + one GET pipelined — no Lua needed. Name the boundary-spike flaw of fixed windows in the interview; it's the classic follow-up.
6.3 Races, clocks, multi-DC, fail-open vs fail-closed
- Races: without Lua, read-refill-write interleaves across hosts over-admit 2–5× under contention. Lua (or
EVALSHA) is the whole fix; keep scripts deterministic and under ~1 ms. - Sticky routing alternative: route same user to same gateway and keep buckets in-process — zero Redis cost, but rebalancing + gateway loss corrupts limits; use only as degraded fallback, say so explicitly.
- Multi-DC: each DC enforces against local Redis with the global quota divided (e.g. 3 DCs → each gets limit/3 with 20% headroom borrowing). True global exactness needs cross-DC coordination (Linearizability per check ≈ death at 1M/s) — so we accept bounded over-admission and document the bound.
- Fail-open vs closed: read path (GET product data) → fail-open (serve, alert); mutating/billing paths → fail-closed (reject with 503 + metric). Decide per-endpoint in the rule, not globally.
- Per-user vs per-IP: users share NAT IPs (mobile carriers!) — pure IP limits cause collateral 429s. Policy: authenticated → limit by user ID (generous) + loose IP backstop (DDoS only); anonymous → stricter IP limit + captcha escalation path.
7. Bottlenecks, Trade-offs & Monitoring
First bottleneck at 10x: Redis shard hot-spotting on viral keys (one celebrity's key = one hash slot) — mitigate with local gateway pre-aggregation (probabilistic admission for ultra-hot keys) + slot splitting by appending :n shards for top-K keys. Next: gateway→Redis AZ bandwidth.
| Approach | Pros | Cons | Use when |
|---|---|---|---|
| In-process (per host) | Zero infra, fastest | Wrong under LB spread; lost on restart | Single-box / SDK client-side |
| Central Redis + Lua (chosen) | Correct across fleet, <2 ms | Redis is now critical path (mitigate: fallback) | Distributed services |
| Sticky-route + local | No central infra | Rebalance skew, failover corruption | Degraded fallback only |
| Metric | Alert if |
|---|---|
| Check p99 added latency | > 5 ms for 5 min |
| 429 rate per scope | > 3× baseline (mis-tuned rule or attack) |
| Redis error / timeout rate | > 0.1% (fail-open is masking — page anyway) |
| Rule version skew (gateways behind) | any gateway > 2 min stale |
| Deny-log Kafka lag | > 2 min |
Interview one-liners
- Q: Why Lua? / A: Makes refill-and-debit atomic across all gateways; without it, concurrent checks over-admit several-fold.
- Q: Exact global limits across DCs? / A: No — divide quota per DC with headroom; exactness would cost a coordination round-trip per request.
- Q: Limiter is down — fail open or closed? / A: Per endpoint: reads fail open, money-mutations fail closed; either way, page on it.
Related
- Coordination Services — rule distribution, leader-free config push
- Linearizability — why we don't pay for exactness
- Stream Processing — deny/allow analytics pipeline
- Cache Patterns and Eviction — gateway rule cache behaviour
- URL Shortener case — a consumer of per-user quotas
- Search Autocomplete case — per-IP throttles on expensive prefix queries
try it live
Steppable request flow, capacity sliders and failure toggles — open the interactive simulator →