Design a distributed rate limiter
"Design a rate limiter for an API gateway: 50,000 requests per second across 100 nodes, per-customer limits, and it must not become the bottleneck."
Step 1: clarify (3 minutes)
What is being limited, and what happens on the boundary? Three questions that determine the algorithm:
Limit granularity Per API key? Per user? Per endpoint? Per (key,
endpoint) pair? The last multiplies your key count.
Burst policy "100 requests per minute" can mean 100 evenly
spread, or 100 all at once then nothing for 59
seconds. These need different algorithms.
Over-limit action Reject with 429, queue, or throttle? Rejecting is
usually right; queueing turns a rate limit into a
latency problem.
Assume: per (API key, endpoint class), burst allowed up to the limit, reject with 429
and a Retry-After header.
Is exactness required? The question behind the question:
STRICT Never allow the 101st request in a window. Requires
coordination on every request, which costs a round trip.
APPROXIMATE Allow occasional small overshoot (say 5%) in exchange
for local decisions at microsecond latency.
Assume approximate, and be specific about the bound, because "approximate" without a number is not an engineering answer. Assume: overshoot bounded to roughly 10 percent under normal operation, and the design must state what makes that bound hold.
Where does it run? At the gateway, before authentication is fully resolved (so it can protect the auth service itself) but after enough parsing to identify the caller.
Scale 50,000 RPS across 100 gateway nodes = 500 RPS/node
Keys ~50,000 API keys x ~6 endpoint classes = 300,000 limit keys
Skew Heavily skewed: the top 20 keys are ~60% of traffic
Latency Under 1 ms added at p99. This is a hard requirement:
a rate limiter that adds 10 ms to every request has
made the API worse than the abuse it prevents.
Step 2: capacity math (3 minutes)
Naive centralised approach
Every request does a Redis INCR: 50,000 ops/sec.
Redis handles ~100k ops/sec/node, so it fits on one node.
But: every request now pays a network round trip.
Same-AZ Redis RTT: ~0.5 ms. Cross-AZ: ~1.5 ms.
-> 0.5 ms added to EVERY request, and a hard dependency on
one Redis for the entire API. Both are unacceptable
against the stated requirement.
State size
300,000 limit keys x ~100 bytes = 30 MB. Trivial.
This is not a storage problem.
Skew consequence
The top 20 keys are 60% of traffic = 30,000 RPS across 20 keys
= 1,500 RPS per hot key. A single Redis key at 1,500 ops/sec is
fine; a single key needing STRICT coordination across 100 nodes
at 1,500/sec is 1,500 round trips/sec on that one key, plus
contention.
-> The hot keys are exactly where centralised coordination hurts
most, and they are also where accuracy matters most.
Local-only approach
Each node enforces limit/100. Zero latency, zero dependency.
Breaks completely under uneven load balancing: if a customer's
requests hash to 3 of 100 nodes, they get 3% of their limit.
-> Unusable alone, and the right BASE for a two-tier design.
The two numbers that force the design: 0.5 ms of round trip on every request, and a customer receiving 3 percent of their limit under uneven routing. Neither pure approach works, which is why the answer is two-tier.
Step 3: the algorithm
Four options. Two are wrong for this workload and it is worth saying why.
FIXED WINDOW
Count per calendar minute, reset at the boundary.
Cheap: one counter, one INCR.
BROKEN: 100 requests at 11:59:59 and 100 at 12:00:01 is 200
requests in two seconds against a "100 per minute" limit.
The boundary burst is 2x the intended rate, always.
SLIDING WINDOW LOG
Store a timestamp per request, count those in the window.
EXACT, and O(n) memory per key. At 1,500 RPS on a hot key with
a 60 s window that is 90,000 timestamps for one key.
Correct and too expensive.
SLIDING WINDOW COUNTER
Weighted blend of the current and previous fixed windows:
count = curr + prev * (1 - elapsed_fraction)
O(1) memory, and it smooths the boundary burst.
Approximation error is small and bounded, and this is what
Cloudflare published as their production choice.
TOKEN BUCKET
Tokens refill at a constant rate up to a capacity.
O(1) memory, expresses BURST (capacity) and SUSTAINED RATE
(refill) as separate parameters, which is what an API product
actually wants to offer.
Take token bucket, because burst and sustained rate are separately meaningful to a customer ("1,000 per minute, bursting to 100") and no other algorithm expresses both.
@dataclass
class TokenBucket:
capacity: float # max burst
refill_per_sec: float # sustained rate
tokens: float
last_refill: float
def try_consume(self, n: float, now: float) -> bool:
# Lazy refill: compute tokens on read rather than running a
# timer per bucket. 300,000 timers would be absurd; this is
# exact and costs one multiplication.
elapsed = now - self.last_refill
self.tokens = min(self.capacity,
self.tokens + elapsed * self.refill_per_sec)
self.last_refill = now
if self.tokens >= n:
self.tokens -= n
return True
return False
Lazy refill is the implementation detail that makes it practical: no background timers, no sweep over 300,000 buckets, and the arithmetic is exact rather than sampled.
Step 4: the two-tier design
This is the answer. Local buckets for the decision, a central authority for distributing capacity.
┌──────────────────────────────────────────────────────┐
│ 100 GATEWAY NODES │
│ │
│ Local token bucket per key. │
│ Decision: in-memory, ~200 ns, no network. │
│ │
│ Each node holds a LEASE: a share of the global │
│ budget, granted for a short interval. │
└───────────────────────┬──────────────────────────────┘
│ async, batched, every 100-500 ms
│ "I used 43 of my 50; give me more"
▼
┌──────────────────────────────────────────────────────┐
│ CENTRAL AUTHORITY (Redis, sharded by key) │
│ Global budget per key per window. │
│ Grants leases proportional to observed demand. │
└──────────────────────────────────────────────────────┘
class LeasedLimiter:
def allow(self, key: str, now: float) -> bool:
bucket = self.local.get(key)
if bucket is None:
# Cold key on this node: allow optimistically with a small
# starter lease, and request a real one. Blocking on the
# central authority for a first request would put the
# network back in the hot path.
bucket = self.local[key] = self._starter_bucket(key)
self.pending_requests.add(key)
if bucket.try_consume(1, now):
self.used[key] += 1
# Ask for more BEFORE running out, so the refill round
# trip overlaps with the tokens we still have.
if bucket.tokens < bucket.capacity * 0.2:
self.pending_requests.add(key)
return True
return False # locally out of budget: 429
async def refill_loop(self):
while True:
await asyncio.sleep(0.1)
keys = self.pending_requests.drain()
if not keys:
continue
# ONE batched round trip for all keys needing refill,
# not one per key. At 100 ms intervals this is 10
# round trips/sec/node, not 500/sec.
grants = await self.central.request_leases(
node_id=self.node_id,
usage={k: self.used.pop(k, 0) for k in keys})
for k, tokens in grants.items():
self.local[k].tokens += tokens
The central authority's allocation policy is where the design gets interesting:
# Redis Lua, atomic. Grant a share of the remaining global budget,
# proportional to what this node has been consuming.
ALLOCATE = """
local key, node, used, window = KEYS[1], ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
local limit = tonumber(ARGV[4])
redis.call('HINCRBY', key, 'consumed', used)
local consumed = tonumber(redis.call('HGET', key, 'consumed') or 0)
local remaining = limit - consumed
if remaining <= 0 then return 0 end
-- How many nodes are actively serving this key right now?
-- Allocating limit/100 when only 3 nodes see the key would give
-- the customer 3% of their limit, which is the failure mode of
-- naive local limiting.
redis.call('HSET', key .. ':nodes', node, ARGV[5]) -- heartbeat
local active = redis.call('HLEN', key .. ':nodes')
-- Grant a bounded slice: enough to cover the refill interval,
-- capped so one node cannot claim the whole remaining budget.
local fair_share = remaining / math.max(active, 1)
local grant = math.min(fair_share, remaining * 0.25)
return math.floor(grant)
"""
Three properties worth defending:
Grants are proportional to observed demand, not equal. A node serving 40 percent of a key's traffic gets roughly 40 percent of the budget, which is what makes the two-tier design work under uneven load balancing.
Grants are capped at a fraction of the remaining budget, so a node that suddenly receives a burst cannot claim everything and starve the others.
Node membership is heartbeated per key, so active reflects nodes actually serving
that key rather than the whole fleet. This is the mechanism that fixes the
3-percent-of-limit failure.
The overshoot bound, derived rather than asserted: in the worst case every node holds
an unused lease when the global budget is exhausted. With grants sized to cover one refill
interval, the maximum outstanding un-consumed capacity is roughly
active_nodes x rate x refill_interval. At 100 ms intervals, 10 active nodes and a 1,000
per minute limit, that is about 10 x 16.7 x 0.1 ≈ 17 requests of slack on a 1,000 limit,
so under 2 percent. Shorter refill intervals tighten the bound and cost more round
trips, which is the actual tuning knob and the honest way to answer "how approximate is
it".
Step 5: hot keys and the skew
The top 20 keys are 60 percent of traffic, and they are the ones where naive designs fail.
For a hot key:
Served by ~all 100 nodes, 1,500 RPS aggregate.
With 100 ms refills, each node makes 10 refill calls/sec for
that key = 1,000 calls/sec on ONE Redis key. Contention.
Fixes, in order:
1. Adaptive refill interval: hot keys get LARGER grants less
often. A node serving 300 RPS of a key takes a 30-request
lease every 100 ms, or a 300-request lease every second.
Fewer round trips, slightly looser bound.
2. Shard the central counter for very hot keys:
key:{id}:shard{0..7}, each holding 1/8 of the budget,
nodes hashed to shards. Removes contention on one Redis key.
3. For the hottest keys, sticky routing at the load balancer so
fewer nodes serve each key, which makes local buckets more
accurate and reduces coordination entirely.
Option 3 is the underrated one. If the load balancer routes by consistent hash on the API key, each key is served by a small number of nodes, local buckets become nearly accurate on their own, and the coordination tier does much less work. The cost is worse load balancing and a rebalance when nodes change, which is a real trade rather than a free win.
Step 6: the response, which is part of the design
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 1000
RateLimit-Remaining: 0
RateLimit-Reset: 43
Retry-After: 43
Retry-After is not a courtesy, it is a load-shedding mechanism. Without it, a
rejected client retries immediately and the rejected traffic costs nearly as much as
serving it would have.
And the headers must be present on successful responses too, so a well-behaved client
can slow down before hitting the limit rather than discovering it by being rejected. The
IETF RateLimit header draft standardises these names, and using the standard names
rather than X- variants is worth doing.
Jitter the reset window across customers. If every customer's window resets at the top of the minute, you have built a synchronised thundering herd into your own API.
Step 7: failure modes
Central authority (Redis) unavailable
-> FAIL OPEN, with a degraded local limit. Each node falls back
to enforcing limit/expected_nodes locally. Customers with
concentrated routing get less than their limit, which is
wrong, and it is much better than the alternative: failing
closed means a Redis outage takes down the entire API.
Alert loudly, because the degraded mode is silently unfair.
A node dies holding an unused lease
-> That capacity is lost until the window resets. With short
leases this is bounded and small. It is the reason grants
should cover about one refill interval rather than one
window.
Clock skew between nodes
-> Token bucket refill uses ELAPSED time on the local monotonic
clock, never absolute wall time, so skew affects nothing.
This is a real advantage of token bucket over window-based
algorithms, which do depend on agreeing what "this minute" is.
Load balancer changes routing
-> Per-key node heartbeats expire and `active` shrinks or grows,
so allocation adapts within a couple of refill intervals.
A customer distributes across many API keys to evade limits
-> Rate limiting is per identity, so identity has to be the right
one. Layer limits: per key, per account, per IP, per ASN.
Costly to evade all four.
Legitimate burst from a large customer
-> This is the case where token bucket earns its place: capacity
expresses the allowed burst explicitly, so the answer is a
product decision encoded in a parameter rather than an
incident.
Fail open is the right answer here and it is the opposite of the RAG access control design, which fails closed. Explaining why the two differ, that a rate limiter failing closed takes down the API while a permissions check failing open leaks data, demonstrates you are reasoning about consequences rather than applying a rule.
Step 8: what changes at ten times the scale
At 500,000 RPS across 1,000 nodes:
The refill traffic itself becomes significant. 1,000 nodes at 10 refills/sec is 10,000 Redis operations per second just for coordination, before any application traffic. The move is hierarchical: nodes coordinate with a regional aggregator, aggregators coordinate globally, so the fan-in at each level stays bounded.
Sticky routing becomes the primary mechanism rather than an optimisation. At 1,000 nodes, a key served by all of them has an unworkable coordination cost, so consistent-hash routing on the API key at the load balancer keeps each key on a handful of nodes and the local bucket becomes nearly authoritative.
Limit configuration becomes its own system. 500,000 keys with per-endpoint overrides, tiered plans and temporary increases is a configuration distribution problem with its own consistency requirements, and it needs to propagate in seconds without a gateway restart.
Global limits across regions get expensive. A cross-region round trip for coordination is 60 to 200 ms, which no rate limiter can pay per request. The practical answer is per-region budgets allocated from a global limit, rebalanced every few seconds, which accepts that a customer can exceed a global limit briefly while shifting regions.
Production evidence
Cloudflare's published rate limiter uses the sliding-window-counter approximation and their write-up reports the approximation error as negligible in practice against real traffic, which is the primary source for choosing an approximate algorithm over an exact one.
Stripe's published rate limiter design describes multiple layered limiters (a request rate limiter, a concurrency limiter, and a fleet-usage limiter) rather than a single one, which is the argument for layering by identity and by resource rather than a single counter.
Google's Site Reliability Engineering book, chapter 21 ("Handling Overload"), describes client-side throttling and per-customer quotas distributed to tasks, which is the two-tier lease model, and its discussion of why the central authority must not be in the request path is the direct justification for the design here.
Envoy's global rate limiting implements exactly this shape: local token buckets with an external rate-limit service, and its documentation is explicit that the local decision is what keeps latency acceptable.
The IETF RateLimit header fields draft standardises RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset, which is the basis for the response design.
Redis's Lua scripting model provides the atomicity the allocation function needs without a locking protocol, because Redis executes scripts single-threaded.
The debate
The case for centralised counting: exact, simple to reason about, and one place to look when a customer disputes their usage. At moderate scale a single Redis handles it, and the added latency is a fraction of a millisecond.
The case for purely local limits: zero latency, zero dependency, no failure mode. And it is wrong under uneven routing, which is normal, so a customer routed to 3 of 100 nodes gets 3 percent of their limit and complains legitimately.
The case for the two-tier lease design: local decisions at microsecond latency, approximately correct globally, and it degrades to local-only when the central authority is unavailable. The cost is a bounded overshoot and more machinery.
My position: two-tier leases with token buckets, an overshoot bound stated as a number, and fail-open on the central authority.
Token bucket rather than sliding window because burst capacity and sustained rate are separately meaningful to a customer, and an API product wants to sell both ("1,000 per minute, bursting to 100"). No window-based algorithm expresses that, and expressing it in the algorithm rather than in documentation is what makes the limit predictable to the caller.
The property I would insist on is that the overshoot bound is derived and stated, not
hand-waved. With grants sized to one refill interval, worst-case slack is roughly
active_nodes x rate x interval, so at 100 ms and ten active nodes on a 1,000-per-minute
limit that is under 2 percent. "Approximate" without a number is not an engineering
answer, and the refill interval is the knob that trades round trips for tightness.
And fail open, deliberately, contradicting what I would do for an authorisation check. A rate limiter that fails closed converts a Redis outage into a total API outage, which is a far worse outcome than a window of unenforced limits. The mitigation is a degraded local limit plus a loud alert, because the degraded mode is silently unfair to customers whose traffic is concentrated on few nodes.
Where I would push back on the framing: "do not become the bottleneck" is the actual requirement and it rules out the obvious design. A centralised counter adds half a millisecond and a hard dependency to every request, and teams build it because it is simple and then discover both. Starting from the latency requirement rather than from the counting problem is what produces the two-tier answer.
Follow-up Q&A
"Why not just use Redis INCR?" Because it adds a network round trip to every request, about half a millisecond same-AZ and one and a half cross-AZ, and it makes one Redis a hard dependency for the entire API. The requirement said the limiter must not become the bottleneck, and a centralised counter is exactly that. It is also worst where it matters most: the hot keys, where 1,500 requests per second means 1,500 coordinated round trips on one key.
"Why token bucket rather than sliding window?" Because burst and sustained rate are separately meaningful to a customer, and token bucket is the only one of the four that expresses both as explicit parameters: capacity is the burst, refill rate is the sustained rate. Sliding window counter is a fine approximation and it can only say "N per window", so any burst policy lives in documentation rather than in the algorithm. Fixed window is just broken, because 100 requests at 11:59:59 and 100 at 12:00:01 is double the intended rate at every boundary.
"How approximate is 'approximate'?" Bounded and derivable, which is the answer that distinguishes an engineering claim from a hope. Worst case, every active node holds an unused lease when the global budget runs out, so the slack is about active nodes times rate times refill interval. At 100 millisecond refills, ten active nodes and a thousand-per-minute limit, that is roughly seventeen requests of slack on a thousand, under two percent. Shortening the refill interval tightens it and costs more round trips, which is the actual tuning knob.
"How do you avoid giving a customer 3 percent of their limit?" That is the failure of
naive local limiting, and the fix is that grants are proportional to observed demand
rather than equal, and that node membership is heartbeated per key. So active counts the
nodes actually serving that key, not the whole fleet, and a node serving forty percent of a
key's traffic gets roughly forty percent of the budget. That is the mechanism that makes
two-tier work under uneven routing.
"The top twenty keys are sixty percent of traffic. What breaks?" Coordination on those keys. If a hot key is served by all hundred nodes at 100 millisecond refills, that is a thousand Redis operations per second on one key. Three fixes: adaptive intervals so hot keys take larger grants less often, sharding the central counter for the very hottest keys so contention spreads, and sticky routing at the load balancer. That last one is the underrated answer, because consistent-hash routing on the API key keeps each key on a few nodes, which makes local buckets nearly accurate and removes most of the coordination.
"Redis goes down. Fail open or closed?" Open, with a degraded local limit of the global limit divided by the expected node count, and a loud alert. Failing closed converts a Redis outage into a total API outage, which is far worse than a window of unenforced limits. It is worth noting this is the opposite of what I would do for an authorisation check, where failing open leaks data. The rule is not "fail open" or "fail closed", it is "compare the consequences".
"Does clock skew affect this?" Not with token bucket, which is one of its advantages. Refill uses elapsed time on the local monotonic clock, so a node whose wall clock is wrong still refills at the correct rate. Window-based algorithms do depend on nodes agreeing what "this minute" is, and that dependency is real if the counting is distributed.
"What do you return to the client?" A 429 with Retry-After, and the RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset headers on successful responses too, using the
IETF draft's standard names. Retry-After is a load-shedding mechanism rather than a
courtesy: without it a rejected client retries immediately and the rejected traffic costs
nearly as much as serving it. And I would jitter the reset windows across customers, so
resetting everyone at the top of the minute does not build a synchronised thundering herd
into the API.
Common misconceptions
"Rate limiting needs a central counter." It needs central allocation. The decision can be local, which is what keeps it out of the latency path.
"Fixed window is fine for simple cases." It permits double the intended rate at every window boundary, always, and a client that discovers this will exploit it.
"Approximate means unbounded." The bound is derivable from the grant size and refill interval, and stating it is what makes the design defensible.
"Fail closed is the safe default." For a rate limiter it converts a dependency outage into a total outage. Compare consequences rather than applying a rule.
"The 429 response is an error path." It is a load-shedding mechanism, and
Retry-After plus limit headers are what make it work.
Interview delivery note
Start from the requirement that rules out the obvious answer: "The constraint that matters is 'must not become the bottleneck'. A centralised Redis counter adds about half a millisecond to every request and makes one Redis a hard dependency for the whole API, so that design is out. Which means local decisions and central allocation, not central counting."
Then commit to the algorithm with a product reason: "Token bucket rather than sliding window, because capacity and refill rate express burst and sustained rate separately, and that's what an API product actually sells: a thousand a minute, bursting to a hundred. No window algorithm can say that."
Give the two-tier mechanism and then the bound, because the bound is what makes it engineering: "Each node holds a lease covering roughly one refill interval, granted proportional to the demand it's been observing, with per-key node heartbeats so the allocation reflects nodes actually serving that key. Worst case, every node is holding an unused lease when the global budget runs out, so the slack is active nodes times rate times interval: at a hundred milliseconds and ten nodes on a thousand-per-minute limit, that's under two percent overshoot."
The line that shows judgement rather than pattern-matching: "and I'd fail open when the central authority is down, with a degraded local limit and a loud alert. That's the opposite of what I'd do for an authorisation check, where failing open leaks data. Here failing closed turns a Redis outage into a total API outage, which is much worse than a window of unenforced limits."
And the detail that shows you have shipped one: "and Retry-After isn't a courtesy, it's
load shedding. Without it a rejected client retries immediately and the rejected traffic
costs nearly as much as serving it would have."
Further reading
- Cloudflare, "How we built rate limiting capable of scaling to millions of domains", for the sliding-window-counter approximation and its measured error.
- Stripe Engineering, "Scaling your API with rate limiters", for layered limiter types.
- Beyer et al., Site Reliability Engineering, chapter 21 ("Handling Overload"), for client-side throttling and distributed quota allocation.
- Envoy's global rate limiting documentation, for the local-bucket plus external-service architecture in production.
- The IETF "RateLimit header fields for HTTP" draft, for the standard response headers.