all_lessons / system_design / cases / C01 C01 / C44

Design a rate limiter

You already know the token bucket. This drill is about the part the whiteboard glosses over: the moment one bucket becomes N buckets behind a load balancer, and "100 requests per minute" stops meaning a single, knowable number.

Source: Vol. 1 Ch. 4 Case drill Applies lesson 15
How to read this case
This is a drill, not a lesson. The mechanism — token bucket, sliding window, the 429 contract — is derived from first principles in foundation lesson 15. Do not re-derive it on the whiteboard; reference it and move on. What an interviewer is actually probing in C01 is the part lesson 15 names but doesn't drill: where the counter lives when there are many nodes, what happens when that store dies, and what one abusive tenant does to all of it. Read linearly, but spend your time in §6.

0. Interview hinge — what 15 already settled, and what it didn't

Lesson 15 gives you everything for a single front door: reject early because a request that never touches business logic costs almost nothing (15's economic first principle); token bucket = "burst up to B, cap the average at r"; emit 429 with Retry-After so clients back off. If the interviewer wanted that, they'd have asked a junior. Walk it in thirty seconds and bank the time.

The hinge
The single-bucket math assumes one counter. The instant you say "we run a fleet of gateway nodes" — and you must, because a single limiter is itself the SPOF lesson 15 warns about — the question becomes where does the counter live, and how wrong is each answer? Every accuracy gain costs a network hop or a coordination round; every cheap option drifts. The senior signal is naming that spectrum and sizing the choice to how wrong you can afford to be. Everything below §3 is that one question.
Invariant to defend
A limit is only meaningful if it maps to a knowable global rate. The invariant: the effective global limit must not silently drift as the fleet autoscales. Per-node local buckets violate this — they make the global limit a function of N and of how evenly the load balancer spreads a tenant's traffic, neither of which is stable. Naming that drift before the interviewer does is the whole drill.

1. Clarify the contract

Treat the prompt as a product contract before a box diagram. What it must do:

What it explicitly need not do: it is not a billing meter (that's an offline aggregation, see §6); it need not be exact to the single request for a soft guard; and it is not a place to ration demand below capacity — lesson 15's rule holds, every 429 should map to real abuse or real backend pressure.

2. Put numbers on the shape

The mechanism is cheap. The cost is in the coordination, so the numbers that steer the architecture are about hops and key cardinality, not bytes.

Limiter checks/s for a 100k req/s API
100k
State for 10M active keys (≈16 B + meta)
~hundreds of MB
Central-store cost per request
+1 round trip
Whale tenant peak (the §6 stress)
50k rps · 1 key

Two of these are load-bearing. The +1 round trip per request means a sub-millisecond Redis hop can exceed the handler time for a cheap endpoint — accuracy is not free. And 50k rps onto one key is a single-partition hot spot: no amount of replication helps a write hot key (07), so the central design that looks exact on the whiteboard melts in production. Hold both numbers; they decide §6.

3. Surface area

Keep it small. The API expresses the decision, never the shard layout.

API / operationWhy it exists
check(key, route, cost) -> {allow|deny, remaining, reset}The hot-path decision; cost enables weighted limiting (15's GitHub-points model).
consume(key, cost)Atomic check-and-decrement where the data lives (see §6, atomicity).
setPolicy(scope, limit, window, tier)Admin plane; read from a local cache on the hot path so the data path only touches counters.

4. Data model

The data model is the first architecture; vague ownership makes every later arrow vague.

policy table (cold, cached)counter / token state (hot)usage event stream (offline)

The split that matters: policy is read-mostly and cacheable per node; counter state is the contended write — its placement is the design question; and a usage stream (an append log of admit/deny events) feeds offline metering and abuse analytics off the hot path. That last one is DDIA Ch. 11 stream aggregation — billing-grade usage is computed by a stream processor over the event log, never read from the live limiter counter, which is approximate and resets every window.

5. Linearized design

Walk a request through the front door. This is lesson 15's flow; the only new step is step 4, where the counter's location forces a decision.

  1. 1. Normalize identity at the gateway: authenticated key beats user beats IP; route policy beats global default.
  2. 2. Read policy from a local cache (preloaded; conservative default on miss to avoid a stampede).
  3. 3. Run the token-bucket check — mechanism per lesson 15, not re-derived here.
  4. 4. The hinge: the bucket state for this key lives somewhere — local to this node, in a central store, or leased from a budget. This choice sets your accuracy and your per-request cost (§6).
  5. 5. On deny, return 429 + Retry-After + RateLimit-* (15's contract). On allow, route inward — the first step that costs real backend resources.
  6. 6. Emit an admit/deny event to the usage stream (Ch. 11) for metering and abuse detection, off the hot path.

6. Deep dives — the part the drill is actually testing

Where the counter lives, and how far each option drifts

Here is the calculation that separates a senior answer from a recited one. Take a hard limit of 100 req/min for one tenant, and a fleet of N gateway nodes. The naive distributed answer is "give each node a local bucket of L = \text{limit}/N." Work it:

That is the whole problem with "divide by N": the global limit is N\cdot L, and both N (autoscaling) and the per-tenant spread (the load balancer and the attacker) are out of your control. The drift isn't a rounding error; it's structural.

THREE PLACEMENTS for the bucket of one tenant (limit = 100/min, N=10 nodes) (a) LOCAL per-node (b) CENTRAL Redis (c) HYBRID lease ───────────────────── ────────────────────── ────────────────────── node1 [L=10] ─┐ node1 ─┐ node1 [lease 12]─┐ node2 [L=10] ─┤ no node2 ─┤ atomic node2 [lease 8]─┤ borrow node3 [L=10] ─┼─ coordination node3 ─┼─▶ [ Redis ] node3 [lease 10]─┼─▶[budget] ... │ ... │ INCR+Lua ... │ every node10[L=10] ─┘ node10─┘ (1 source) node10[lease 7] ─┘ ~200ms ───────────────────── ────────────────────── ────────────────────── hops/req : 0 hops/req : +1 (per req) hops/req : ~0 (amortized) accuracy : ±N·L drift, accuracy : EXACT global accuracy : eventually exact, client controls limit small transient over/ effective limit under-shoot fails by : over-admit on fails by : hot key melts fails by : drift between syncs skew / autoscale one tenant (07), ≈ (sum of leases not (160 vs 100) SPOF (13) yet returned)

The map is the answer to "how do you rate limit across N autoscaling nodes." (a) Local is free and fine for a soft saturation guard where being 1.6× off costs nothing. (b) Central Redis gives an exact global limit — the single source of truth — at the price of a round trip per request and a dependency on the store. (c) Hybrid lease sits between: each node borrows a slice of a global budget every ~200ms and returns the residual, so the hot path stays local (≈0 hops) and the drift is bounded by the un-returned leases — DDIA Ch. 9's eventual consistency applied to a counter. Choose by how wrong you can afford to be, not by reflex.

Why the central path must be atomic where the data lives

The naive central implementation — read counter, compare to limit, write counter+1 — has the textbook race lesson 15 names: two nodes read 99 under a limit of 100, both decide they're under, both write 100, and you've admitted 101. This is DDIA Ch. 7's atomic single-object operation: the check-and-increment must be atomic at the store, not split across a read and a later write from the client. Redis INCR is atomic; to also apply the limit and TTL in one round trip you ship a Lua script (executed atomically server-side) or use CL.THROTTLE (redis-cell's GCRA token bucket in one call). The general law: a distributed counter is correct only if its read-modify-write is atomic where the data physically lives. Move the decision to the client and you've reintroduced the race.

Fail-open vs fail-closed when the store dies

Option (b) makes Redis a dependency on every request, so you must decide its failure mode before it fails. Fail-open (allow on store error) keeps the API serving but removes all protection — fine for low-risk reads, catastrophic for login or payment endpoints where the limiter is an abuse control. Fail-closed (reject) protects the backend but converts a Redis blip into a full outage. The senior move is neither: degrade to per-node local buckets on store failure (option a as a fallback) — you lose global accuracy but stay bounded, and you alert. That degradation path is exactly why the hybrid design earns its complexity: it already has local buckets to fall back to.

The whale tenant: a hot key, not a capacity problem

One tenant at 50k rps against a single counter key is a write hot key (lesson 07). Replication does not help — every request is a write to the same key, so it lands on the same primary partition regardless of replica count, and that one shard melts while the rest of Redis idles. This is the sharpest question in the drill; it's handled below.

7. Trade-offs

ChoiceBuysCostsChoose when
Local per-node vs central counterzero hops, no shared stateeffective limit drifts as N·L; client controls it; over-admits on skew/autoscalesoft saturation guard where being ~2× off is harmless
Central Redis vs hybrid leaseexact global limit, single source of truth+1 round trip/req, store is SPOF (13) and hot key (07)hard contractual quota where exactness is the product
Hybrid lease vs centrallocal hot path + bounded driftmore code; transient over/under-shoot between syncsvery high RPS where the hop or hot key hurts
Fail open vs fail closedavailability vs protectionabuse leakage vs self-inflicted outageread endpoints fail open; auth/payment degrade to local buckets

The sharpest trade-off is the first one, and it's a trap if you state it as "local is approximate." The senior framing is that local doesn't have an error bar — it has an error the client controls, because the effective limit is (nodes the client touches) × L, and a motivated client spreads to touch more nodes. So "approximate" is fine for a saturation guard but disqualifying for a billing quota, where someone is paid to find that drift.

8. Failure modes

FailureMitigation
Effective limit drifts as N autoscalesUse central or hybrid for hard limits; for local, treat N·L as soft and alert on the transient over-admit.
Redis (central counter) diesDegrade to per-node local buckets (bounded, degraded accuracy) + alert. Decide fail-open vs fail-closed per endpoint class up front.
Whale tenant hot key (50k rps on one key)Shard the counter by key + time-slice, or allocate local leases from a central budget — see §9.
Retry storm after 429Return Retry-After; require exponential backoff + jitter (lesson 13); alert on synchronized retries.
Clock skew across nodesRefill from monotonic local time or server-side time at the store; never trust client timestamps for the window.

9. Interview prompts you should be ready for

  1. How do you rate limit across N autoscaling gateway nodes? (senior answer) Name the spectrum: local per-node (free, but effective limit is N·L and drifts — at limit 100 it over-admits to 160 when N goes 10→25 mid-flight), central Redis (exact global limit, +1 hop/req, hot-key + SPOF risk), or hybrid leases that borrow from a global budget every ~200ms (eventually exact, local hot path). Pick by how wrong you can afford to be.
  2. Why isn't "divide the limit by N" good enough? (senior answer) Because the effective global limit is N·L, and both N (autoscaling) and per-tenant spread are outside your control — a client that fans out to touch all N nodes gets the full N·L, and during a scale-up the old high-L buckets coexist with new low-L ones, so it over-admits. The error is structural and client-controlled, not a rounding error.
  3. Your Redis limiter is the hot key for one whale tenant at 50k rps — keep global accuracy without melting it. (senior answer) A single key is one partition; replication won't help a write hot key (07). Two real fixes: (1) shard the counter by key + time-slice — split the whale's bucket into K sub-counters (key:shard0..K) and limit/K on each, summing for the global view, spreading 50k rps across K partitions; or (2) local lease allocation — the central store hands each gateway node a lease (say 1/N of the budget) every ~200ms, so the node decides 50k rps locally and only touches the budget at the sync interval. Lease is usually better at this scale: it converts 50k round-trips/s into ~5/s while keeping global accuracy eventually exact.
  4. Your limiter's Redis goes down — what happens? (senior answer) Decide the mode before it dies. Don't fail-open globally (removes abuse protection on login/payment) or fail-closed globally (turns a blip into an outage). Degrade to per-node local buckets — still bounded, degraded accuracy — and alert. The hybrid design already has those local buckets, which is half of why it earns its complexity.
  5. Why must the central check-and-increment be atomic, and where? (senior answer) DDIA Ch. 7 atomic single-object op: a split read-then-write races (two nodes read 99, both admit, you get 101). It must be atomic at the store — a Redis Lua script or CL.THROTTLE does check + decrement + TTL in one server-side call. Moving the decision client-side reintroduces the race.
  6. How is billing-grade usage different from the limiter counter? (senior answer) The live counter is approximate and resets every window — never bill from it. Emit an admit/deny event to a usage log and aggregate it with a stream processor (DDIA Ch. 11). The limiter protects the backend in real time; metering is an offline, exactly-once aggregation over the event stream.
  7. Token bucket vs sliding window here? (senior answer) Mechanism is lesson 15 — I wouldn't re-derive it. Token bucket for burst-friendly public APIs (allow B, cap average r); sliding-window counter when boundary fairness matters at O(1) memory. The interesting choice in C01 isn't the algorithm, it's where its state lives.
  8. How does this relate to load shedding (lesson 13)? (senior answer) Same goal — keep the backend below the saturation cliff — different timing. Rate limiting is planned, per-key admission control; shedding is the emergency reaction once already overloaded. The 429-vs-503 split encodes it: 429 = "you exceeded your quota," 503 = "the whole service is hurting." Both want backoff + jitter from clients.

Related foundation lessons

The mechanism — token bucket, the 429 contract, why the edge is the cheapest place to reject — lives in lesson 15; this drill deliberately does not re-derive it. The distributed failure side leans on lesson 13 for the fail-open/closed decision and backoff+jitter (load shedding is C01's sibling — same goal, different timing), and on lesson 07 for why a whale tenant's single counter key is a write hot key that replication can't fix. The eventual-consistency reasoning behind hybrid leases is lesson 09, and the per-hop latency cost of a central store is lesson 02.

Rate limiting and edge Fault tolerance Partitioning Consistency & CAP Latency and throughput
Takeaway
The token bucket is lesson 15's; don't re-derive it in the room. C01 is won on the next sentence: a limit is only meaningful if it maps to a knowable global rate, and the moment you have N autoscaling nodes, "divide by N" makes that rate drift to whatever the client and the autoscaler choose (100 → 160 on a 10→25 scale-up). Central Redis buys exactness for a hop per request and a hot key; hybrid leases buy a local hot path for bounded drift. Make the atomicity, the fail-mode, and the whale-tenant decisions out loud — that's the senior signal.