System design
Part 2 of 6 · Rate limitingToken Bucket vs Leaky Bucket vs Sliding Window
Three classic limiters: token bucket (burst + sustained rate), leaky bucket (smooth drain), sliding window (fairer than fixed windows). Interviews want tradeoffs, not just names.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
Production gateways are not one algorithm. AWS API Gateway and Stripe-style APIs are token-bucket flavored (rate + burst). NGINX limit_req is leaky / delayed. Redis tutorials teach sliding counters because they are cheap and fair enough. Interviews score you on burstiness, memory, boundary fairness, and whether you can implement the debit atomically.
By the end of this lesson you should be able to:
- Draw token refill, leaky drain, and sliding-window weight on a whiteboard
- Pick an algorithm from burst vs smoothness vs memory, not from a blog title
- Walk a 60-second window with numbers (2× seam, sliding estimate)
- Cost a GraphQL field as
Ntokens without inventing a second limiter - Say what is still true if the client “already paces”
Interactive public API: token bucket vs leaky-as-queue
Prefer
Token bucket (burst b, refill r)
A page load that fans out 8 GETs should work after idle. Burst is a product knob, not an accident at the minute boundary.
- Capacity b ≈ r × burst_seconds. Idle clients refill; busy clients sit at r.
- AWS throttling is this pair: rate + burst.
- Reject with 429 when empty — do not silently queue user-facing HTTP.
Alternative
Leaky bucket that queues
Constant egress is perfect for SMS and bank APIs. Queuing a browser request adds latency the user did not ask for.
- Shape = delay; police = drop. Mixing the two in one sentence is the trap.
- Queue memory is O(burst), not O(1), if you shape.
- NGINX limit_req delay is this family — use it outbound, not as your only UX limiter.
One request, three limiters
The interview is the branch, not the names.
- 1
Request arrives with a cost
Usually 1. Search or GraphQL may cost 10. Cost is tokens or window slots — pick one unit and stick to it. - 2
Token bucket: refill by elapsed time
tokens = min(capacity, tokens + elapsed × r). If tokens ≥ cost, debit and allow. - 3
Leaky: enqueue or drop, drain at r
If you queue, you shaped. If you drop, you policed. Downstream sees a smooth line either way. - 4
Sliding: update the window, then decide
Log prunes timestamps. Counter weights prev + curr. Under limit → allow; else 429 + Retry-After. - 5
Fixed window alone
INCR in [T, T+W). At the seam you can admit 2×. Layer it under a finer limiter or do not ship it as the only gate.
Scorecard
| Algo | Burst | Smooth egress | Memory | Edge fairness | Default when |
|---|---|---|---|---|---|
| Fixed window | Accidental 2× | Poor | O(1) | Bad | Coarse daily/hourly quota, layered |
| Sliding log | Controlled | Good | O(requests in W) | Excellent | Low-volume, high-value (admin, OTP) |
| Sliding counter | Controlled | Good | O(1) | Good (approx) | Public APIs at scale |
| Token bucket | Intentional | Medium | O(1) | Good | User-facing APIs, SDKs |
| Leaky bucket | None (or queued) | Excellent | O(1) or O(queue) | Good | Fragile downstreams |
Decisions
- 1
Request + cost
- nextWhich limiter?
- ?
Which limiter?
- tokenRefill by elapsed
- leakyEnqueue or drop
- slidingUpdate window
- 3
Refill by elapsed
- nexttokens ≥ cost?
- ?
tokens ≥ cost?
- yesDebit + allow
- no429
- 5
Debit + allow
- 6
429
- 7
Enqueue or drop
- nextDrain at constant r
- 8
Drain at constant r
- 9
Update window
- nextUnder limit?
- ?
Under limit?
- yesAllow
- no429 + Retry-After
- 11
Allow
- 12
429 + Retry-After
Diagrams - step by step
Three small diagrams for token bucket, leaky bucket, and sliding window. Step numbers in the labels give the animation order. The lesson map under Diagram 1 plays those steps.
Diagram 1 - Happy path: token bucket check and debit
Decisions
- 1
Step 1 Request with cost c
- nextStep 2 Read tokens and the last refill time
- 2
Step 2 Read tokens and the last refill time
- nextStep 3 Refill - tokens = min of capacity and tokens + rate x elapsed
- wall clock jumps backwardsFailure path - negative elapsed time, use a monotonic clock
- 3
Step 3 Refill - tokens = min of capacity and tokens + rate x elapsed
- nextStep 4 tokens at least c?
- ?
Step 4 tokens at least c?
- yesStep 5a Debit c and allow
- noStep 5b Reject with 429 and Retry-After
- 5
Step 5a Debit c and allow
- nextStep 6 Save tokens and timestamp
- 6
Step 5b Reject with 429 and Retry-After
- 7
Step 6 Save tokens and timestamp
- 8
Failure path - negative elapsed time, use a monotonic clock
Read the balance and the last refill time, add tokens for the elapsed interval, and cap at capacity. Debit the cost when the balance covers it, then save the new balance and timestamp. Otherwise return 429 with Retry-After. If the wall clock jumps backwards, elapsed time goes negative: use a monotonic clock.
Lesson map
Token Bucket vs Leaky Bucket vs Sliding Window
Diagram 1 walks 7 steps from Step 1 Request with cost c through Step 6 Save tokens and timestamp.
Architecture. Step 1 Request with cost c Ready. Step 2 Read tokens and the last refill time Ready. Step 3 Refill - tokens = min of capacity and tokens + rate x elapsed Ready. Step 4 tokens at least c? Ready. Step 5a Debit c and allow Ready. Step 5b Reject with 429 and Retry-After Ready. Step 6 Save tokens and timestamp Ready. Failure path - negative elapsed time, use a monotonic clock Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB A["Step 1 Request with cost c Ready"] B["Step 2 Read tokens and the last refill time Ready"] C["Step 3 Refill - tokens = min of capacity and tokens + rate x elapsed Ready"] D["Step 4 tokens at least c? Ready"] E["Step 5a Debit c and allow Ready"] F["Step 5b Reject with 429 and Retry-After Ready"] G["Step 6 Save tokens and timestamp Ready"] X["Failure path - negative elapsed time, use a monotonic clock Ready"] A -->|continues| B B -->|continues| C C -->|continues| D D -->|yes| E D -->|no| F E -->|continues| G B -->|wall clock jumps backwards| X
Diagram 2 - Failure path: leaky bucket shaping on an interactive API
Sequence
- 1
Interactive users → Leaky bucket queue
Step 1 burst of 500 requests in 1 s
- 2
Leaky bucket queue → Search API
Step 2 drains at a fixed 50 per s
- 3
Leaky bucket queue → Leaky bucket queue
Step 3 450 requests wait in the queue
- 4
Interactive users
Step 4 the last request waits about 9 s, users time out and retry
- 5
Interactive users → Leaky bucket queue
Step 5 retries refill the queue and latency stays high
- 6
Interactive users
Fix - token bucket for interactive APIs, keep leaky shaping for egress to SMS or payment partners
Shaping interactive traffic through a leaky queue holds the last request for seconds. Users time out and retry, which refills the queue, so latency stays high. Use a token bucket for interactive APIs. Keep leaky shaping for egress to SMS or payment partners.
Diagram 3 - Decision: token vs leaky vs sliding
Decisions
- ?
Step 1 Bursty clients and bursts are OK?
- yesToken bucket
- noStep 2 Downstream needs a smooth rate?
- 2
Token bucket
- ?
Step 2 Downstream needs a smooth rate?
- yesLeaky bucket
- noStep 3 Fairness over a rolling window?
- 4
Leaky bucket
- nextStep 4 Drop or delay the excess?
- ?
Step 3 Fairness over a rolling window?
- yesSliding window
- noFixed window
- 6
Sliding window
- Wrong pick - claim it is exactThe counter variant is an approximation
- 7
Fixed window
- Wrong pickBoundary burst near 2x
- ?
Step 4 Drop or delay the excess?
- dropPolicing - meter
- delayShaping - queue
- 9
Policing - meter
- 10
Shaping - queue
- 11
The counter variant is an approximation
- 12
Boundary burst near 2x
Bursty clients that may burst get a token bucket. A downstream that needs a smooth rate gets a leaky bucket: drop the excess (policing) or delay it (shaping). Fairness over a rolling window is a sliding window, and the counter form is an approximation, not an exact log. A fixed window is the tempting shortcut, and it admits about 2x at the boundary.
Fixed window — why it loses as the only gate
Partition time into aligned buckets. Count in the current bucket; reset at the boundary.
A client sends limit at 11:59:59 and limit again at 12:00:00. Downstream sees 2× in about two seconds. That is not a burst you designed — it is a clock artifact.
Use it for “1000/day” sitting under a per-second token bucket, not as the only limiter on /v1/search.
Sliding log vs sliding counter
Log: store a timestamp per request (Redis sorted set). Prune older than now - W, count, admit if < limit. Exact over any rolling interval. Memory is O(RPS × W) per key. Unique members: timestamp + sequence, or same-ms requests collide and vanish.
Counter (hybrid): keep previous and current fixed-window counts.
estimated = prev × (1 − elapsed/W) + currAdmit if estimated + cost ≤ limit. O(1) memory. Approximate: it assumes the previous window was roughly uniform. Cloudflare-class deployments publish tiny wrong-decision rates at huge volume. Do not claim bit-exact.
Token bucket — burst is a feature
A bucket holds up to capacity tokens. Tokens refill at rate r. A request costs cost (usually 1). Empty → 429 (or wait, which is leaky-adjacent).
- Burst = capacity (idle → full).
- Sustained =
r. - Capacity heuristic:
b ≈ r × burst_seconds. 10 RPS with 2s of burst → capacity 20.
AWS API Gateway throttling is this pair: rate = refill, burst = bucket size. Stripe’s public limiter writeup is the same family.
A full-bucket burst can still smash a fragile partner. If downstream needs a smooth line, you wanted leaky on the egress, not a bigger token bucket on the ingress.
Leaky bucket — police vs shape
Requests enter a bucket that leaks at constant r. If full: drop (police) or queue (shape).
| Mode | What the client feels | What downstream feels |
|---|---|---|
| Police (meter) | Immediate 429 | Smooth, no queue |
| Shape (queue) | Extra latency | Smooth, bounded queue |
Some texts treat “leaky as a meter” as a token drain with no stored burst. Say which one you mean. Token bucket when controlled bursts are a feature. Leaky when the partner SLA is “N per second, no spikes.”
Weighted cost
GraphQL depth, /search, and LLM tokens are not one request. Debit cost tokens (or cost slots) with the same algorithm.
allow(key, cost=10) # search
allow(key, cost=1) # health / cheap GETDo not invent a second limiter for “expensive routes” unless you also need a separate fairness key. One bucket, different debit. Tenant fairness on top: noisy neighbors.
Client pacing does not replace the server
SDKs should run a client-side token bucket to avoid 429 storms. Attackers and buggy retries will not. The server limiter is the SoT. Return 429 + Retry-After. Honor jitter on the client.
Clocks: in-process time.monotonic() / performance.now() are fine for a local bucket. Distributed refill must not trust N client wall clocks — Redis TIME inside Lua.
Working sketches
Sandboxes are in-memory. Redis stays on the next page.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Interview Q&A
Token bucket vs leaky bucket in one sentence each?
Answer
Token allows a designed burst up to capacity, then clamps to refill r. Leaky enforces smooth output at r and either drops or queues the rest. Token for interactive APIs; leaky for fragile egress.
Why not a fixed window as the only limiter?
Answer
Boundary burst ≈ 2× the limit in ~2 seconds. INCR + EXPIRE is fine as a coarse quota under a finer limiter, not as the public API gate.
Sliding log vs sliding counter?
Answer
Log is exact and O(requests in window) (sorted set). Counter is O(1) and approximate (weighted prev+curr). Default at scale is the counter. Never call the counter bit-exact.
How do you pick capacity b for a token bucket?
Answer
b ≈ r × burst_seconds. Burst_seconds is a UX number: how many parallel calls a legitimate page load may fire after idle. Too big and you melt downstream; too small and you 429 real users.
How do you rate-limit GraphQL or search?
Answer
Debit cost tokens (complexity, depth, or a route weight) from the same bucket. A cheap GET /healthz costs 1; nested GraphQL may cost 50. Count-of-requests limits are trivial to bypass with one fat query.
Does a client-side token bucket mean the server can skip enforcement?
Answer
No. Clients reduce 429s. Attackers, NAT, and retries ignore your SDK. Server limiter is authoritative; return Retry-After.
Police vs shape?
Answer
Police drops excess now (429 / 503 on the partner socket). Shape queues and delays to hold a constant egress. Interactive HTTP should police. SMS / bank batching may shape with a bounded queue and a max wait.
NGINX limit_req — which family?
Answer
Leaky / delayed: excess is delayed or dropped (nodelay is closer to police). It is not a token bucket with a separately tunable burst-as-tokens model like AWS API Gateway.
What clock do you refill with?
Answer
Local sandbox: monotonic. Distributed: Redis TIME (or a single source) inside the atomic script so N gateways do not disagree. Non-monotonic wall clocks skip refill or jump windows.
When is a sliding log worth the memory?
Answer
OTP, password-reset, admin RPCs — low RPS, high cost of being wrong. At 10k RPS per key, use a counter or token bucket.
Pitfalls
For (1) GET /v1/me after a page load, (2) outbound SMS, (3) GraphQL search: write algorithm, rate r, burst/capacity or queue bound, and cost. Then run the playground and change cost on the TypeScript bucket to 10. If you still used a fixed window for (1), show the 2× seam.
Go Deeper
- AWS API Gateway throttling
- Stripe Engineering — rate limiters
- Redis — five rate limiters
- NGINX
limit_req
Cluster: hub · next Redis + Lua