← System Design
Classic Case Studies

Design a Distributed Rate Limiter

Watch: "Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer" — Hello Interview
In simple words

A theme park ride that lets in 20 people every 5 minutes. Everyone else waits or is told to come back later. The ride never gets overloaded, and one big group can't hog every slot.

Problem Statement

Design a rate limiter that caps how many requests a client can make in a time window ("100 requests per minute per API key"), shared across a fleet of API servers. It protects services from abuse and overload and enforces paid plan limits. It sits on every request, so it must be fast and must not become the thing that takes the API down.

Functional Requirements

Non-Functional Requirements

Memory estimate: 10M keys x ~100 bytes of counter state = ~1 GB -> fits in a Redis cluster
Throughput: 1M checks/s -> a sharded Redis cluster (~100K ops/s per node) -> 10-20 nodes

Core Entities

API Design

Internal (called by the gateway / middleware for every request):
  check(key, rule_id, cost=1) -> { allowed: bool, remaining: int, reset_at: ts }

To clients (HTTP headers on responses):
  429 Too Many Requests
  Retry-After: 20
  X-RateLimit-Limit: 100
  X-RateLimit-Remaining: 0
  X-RateLimit-Reset: 1727600000

Admin:
  PUT /v1/rules/{id} { limit, window, burst, scope }

High-Level Design (HLD)

Rate limiting at the gatewayClientAPI gateway(limiter middleware)Redis clustertoken bucketsBackend services429 Too ManyRequestscheck(key)allowedrejected
Every request checks a shared counter first; rejected requests never reach the backend.
client --> API gateway / middleware --check()--> [ Redis cluster ]
                 |  allowed?                        (counters, sharded by key)
                 |-- no  -> 429 + headers
                 |-- yes -> backend services

[ rules store (DB) ] --> pushed / polled into each gateway's memory

Low-Level Design (LLD)

Token bucket decision for one requestRequest(key, rule)Refill:tokens += elapsed ×rate(capped at burst)tokens ≥ cost ?Allowtokens −= costReject 429Retry-AfterLua script(atomic)yesno
The whole read-refill-decide-write runs as one atomic Redis script, so concurrent requests can't overspend.

Choosing an algorithm

AlgorithmHowProsCons
Fixed windowcounter per key per minutesimplest, 1 key2x burst at window edges
Sliding logtimestamps of each requestexactmemory per request
Sliding window countercurrent + previous window, weightedsmooth, 2 countersapproximate
Token buckettokens refill at rate r, capacity ballows bursts, smooth rate2 fields, needs atomic update
Leaky bucketqueue drained at fixed ratesmooth outputadds queueing delay

Token bucket is the usual answer: it expresses "average 100/min, bursts up to 20", and state is two numbers per key.

Atomic token bucket in Redis (Lua script)

-- KEYS[1] = "rl:{api_key}:{rule}"   ARGV = rate/s, capacity, now_ms, cost
local b = redis.call("HMGET", KEYS[1], "tokens", "ts")
local rate, cap, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local tokens = tonumber(b[1]) or cap
local ts     = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)   -- refill
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], math.ceil(cap / rate * 1000) + 1000)
return { allowed and 1 or 0, math.floor(tokens) }

The script runs atomically on the Redis node, so concurrent requests from different gateways can't both spend the last token. Keys expire when idle, so memory tracks active clients only. Use a single clock source (the gateway's time is fine if clocks are synced; or Redis TIME).

Sliding window counter (cheaper alternative)

count = prev_window_count * (1 - elapsed_fraction) + current_window_count
allow if count < limit;  INCR current window key (TTL = 2 windows)

Failure handling

Follow-up Questions

Round trips to Redis add latency on every request. Can you avoid it?
Batch: each gateway keeps local counters and syncs with Redis every ~100 ms, or leases a slice of the limit (e.g. 10 tokens at a time). You trade some accuracy (small overshoot) for far fewer network calls.
Multi-region: is the limit global or per region?
Usually per region with the limit split (or per region with independent limits), since synchronous cross-region counters would add ~100 ms. Global exactness is rarely worth that; periodic async sync of counts gets close.
How do you rate-limit by IP when many users share one NAT IP?
Prefer authenticated identities (user id, API key); use IP limits only for unauthenticated endpoints, with higher thresholds, and combine with other signals (device, behaviour).
Fixed window allows 200 requests in 2 seconds across a boundary. Why, and does it matter?
100 at 11:59:59 and 100 at 12:00:00 are in different windows. For abuse protection a sliding window or token bucket avoids it; for billing quotas fixed windows are often fine.

Related