Rate Limiters Explained: Token Buckets, Sliding Windows & the Redis Race
One user’s flood can sink the ship: how fixed windows, sliding windows, token buckets, and leaky buckets decide who gets in — plus the atomic Redis trick and the 429 playbook, with the arithmetic done by hand.
Explain it like I’m five
Picture a nightclub with one very calm bouncer. He lets people in steadily — one at a time, no rush — and when the club is full, he holds up a hand: come back in five minutes. He is polite, he never loses count, and he does not care how badly you want in.
A rate limiter is that bouncer for your API. One login request is fine; ten thousand a second from one script is how you get outages, melted databases, and a very bad on-call night. The limiter counts requests, lets the reasonable ones through, and answers the rest with a firm, standardized no — HTTP 429 Too Many Requests — plus a hint about when to come back. Everything in this guide is just the bouncer’s counting trick, refined four different ways.
Intuition: why every backend needs a bouncer
“Design a rate limiter” shows up in almost every system-design interview — and most candidates fumble it, because they recite an algorithm instead of reasoning about the system. The interview is about what you protect, from whom, where the decision happens, and what breaks when the tradeoffs are wrong. By the end of this guide you’ll name the trap in the simplest design, derive two better ones, and know how the real systems build it.
Three reasons the limiter exists. First, abuse: your login endpoint is a credential-stuffing target — without a limiter, a script can brute-force accounts or burn your SMS budget all night. Second, cost: every request costs compute, and LLM APIs bill per token — an unbounded client can turn your invoice into a horror story. Third, fairness: one noisy tenant must not starve the other forty-nine sharing the same pool.
There are four classic ways to count, and the interview rewards you for reaching each one deliberately. Fixed window counts requests inside each clock minute — dead simple, but it bursts at the boundary. Sliding window counts the last sixty seconds relative to now — precise, but storing every timestamp costs real memory, so production versions approximate. Token bucket lets clients spend saved-up tokens and tolerates bursts. Leaky bucket drains at a constant drip and smooths everything into a steady stream.
Put the limiter at the API gateway, answer 429 with a Retry-After header, and name the tradeoffs: fixed window is simple but bursts at boundaries, token bucket absorbs bursts, leaky bucket smooths them — then ask what the system actually needs.
How the four designs work — and the race at the heart of it
Fixed window: the trap at the boundary
The simplest counter: each key (a user, an IP, an API key) gets a counter and a one-minute window. First request sets the counter with a 60-second expiry; each request increments; past the cap, every request gets a 429 until the window rolls over. One integer per key — correct, cheap, and hiding a flaw you should volunteer before the interviewer asks.
A cap of 100 requests per minute. A client fires 100 at 12:00:59 — the last second of the window — then 100 more at 12:01:01, the first second of the next. Both counters saw at most 100, so both waves were admitted: 200 requests in 2 seconds against a stated limit of “100 per minute.” The counter never noticed, because windows are a bookkeeping convenience, not a physical fact. Any adversary, and any thundering retry loop, will discover this seam.
Sliding window log: exact, expensive
Fix the boundary by abandoning fixed windows entirely. Keep a sorted log of every request’s timestamp — a Redis sorted set is the canonical store. On each request, evict entries older than sixty seconds, count what remains, and admit only if the count is under the cap. There is no boundary, so there is no burst: the count always reflects exactly the last sixty seconds.
The cost is memory proportional to traffic. A client allowed a million requests per minute needs a million timestamp entries per key. For low-rate endpoints like login this is perfect; for a firehose API it is a Redis bill shaped like a hockey stick. Production systems therefore approximate.
Sliding window counter: the approximation that wins
Keep just two integers: the count for the finished minute and the count for the current minute. To estimate “requests in the last sixty seconds,” blend them: at 15 seconds into the current minute, the estimate is curr + 0.75 × prev, weighting the previous minute by how much of it still overlaps. Two integers per key — constant memory — and the error stays small.
Notice what happened: exactness traded for O(1) memory. In interviews, the follow-up is always “how much memory?” — the sliding-window log costs O(requests per window) per key, the counter costs O(1). Say both, then pick the counter for high-throughput keys and the log for precision-critical ones like login lockout.
Token bucket vs leaky bucket: cousins, opposite temperaments
The token bucket holds a fixed number of tokens — capacity B — and refills at a steady rate r tokens per second. Each request spends one token. Tokens arrive continuously, so a quiet client accumulates savings and spends them at once: a full bucket of 100 admits a 100-request burst instantly. The average rate is capped at r, but bursts up to B are legal. Empty bucket? 429.
The leaky bucket flips the idea. Requests pour into a queue and drain out at a fixed rate — ten per second, steady, no exceptions. A burst of 100 does not get through in one second; it gets processed ten per second, and overflow waits or drops. Where the token bucket absorbs bursts, the leaky bucket flattens them — ideal for a downstream service, a database, or a payment rail that needs a calm, steady drip.
Token bucket: bursts spend savings; the refill rate sets the sustainable speed. Leaky bucket is the mirror image — it holds requests and drains them at the fixed rate.
Token bucket admits bursts up to capacity and caps the average rate; leaky bucket admits a constant rate and absorbs bursts as queueing. Bursty API traffic: token bucket. A fragile downstream: leaky bucket.
The distributed race — and the Lua fix
One server? In-memory counters, done. But real systems run dozens of API servers, so the counters have to live somewhere shared. That is Redis. And that is where the interview gets interesting, because the naive implementation has a race.
The naive flow is two steps: read the counter, then increment it if under the limit. Two servers handle two requests from the same client at nearly the same instant. Both read 99 against a limit of 100. Both decide “under the limit.” Both increment. The limit was 100; 101 requests got through. Widen that gap across forty instances and the over-admission scales with the fleet. The check and the increment must be one indivisible operation.
Redis gives you exactly that with Lua scripts. Redis executes a Lua script atomically — no other command, from any client, can interleave with it — so a check-and-increment script reads the true current count, decides, and writes the increment before any other instance’s script may begin. Load the script once, invoke it per request with EVALSHA (the client library hashes the script body and sends only the hash after the first call, so the wire cost is about one INCR), and the race is structurally impossible rather than merely unlikely. One caveat worth naming: the atomicity holds per shard — and a failover can briefly lose the most recent writes, which over-admits rather than under-admits. The code section below is the real pattern.
Where the limiter lives — and how it says no
Enforce at the API gateway or edge, before the request touches your services: a rejected request should never consume backend compute. The gateway answers with 429 Too Many Requests — the status code RFC 6585 defined for exactly this (“the user has sent too many requests in a given amount of time”) — and a Retry-After header telling the client how long to wait before trying again. RFC 6585 also says 429 responses must not be stored by caches, which matters when a CDN sits in front of you.
Good APIs also advertise their headroom: OpenAI answers with x-ratelimit-remaining-requests and x-ratelimit-remaining-tokens headers, Anthropic with anthropic-ratelimit-requests-remaining, so well-behaved clients throttle themselves instead of discovering the cliff at full speed. If your gateway sets those headers too, say so — it is a small detail that signals production experience.
LLM APIs: rate limiting at planetary scale
The largest rate limiters in production right now meter AI traffic. OpenAI and Anthropic both enforce per-minute limits on two dimensions at once — requests per minute and tokens per minute — tiered by usage, and either dimension trips a 429. Their clients are the exact retry storms this guide is about: every inference server running continuous batching behind the limiter hammers retries the moment it gets throttled.
Login, signup, and password-reset endpoints sit behind strict per-IP and per-account limiters at the gateway. This is abuse prevention: credential-stuffing scripts are throttled with a sliding window — precise enough that a human typing a wrong password twice never notices, but a 10,000-per-second script dies at the edge before it touches the auth service or the SMS budget.
LLM APIs meter two scarce resources simultaneously: API calls and GPU tokens. OpenAI enforces requests-per-minute and tokens-per-minute tiers; Anthropic splits tokens further into input and output tokens per minute with separate anthropic-ratelimit-*-remaining headers. Hitting either dimension returns 429 with a Retry-After, and the token dimension is what binds: one long-context request can burn the token budget while the request counter barely moves. This is how the providers protect their GPU fleet — and why the token bucket is the natural mental model for their clients.
Multi-tenant SaaS and payment rails use per-tenant limiters so one customer’s flood cannot starve the rest. A payments API gives each tenant a token bucket sized to their plan — bursts for flash-sale checkouts, a capped average otherwise — enforced at the gateway with per-tenant keys in Redis. The limit is a product feature here: the fairness guarantee that makes sharing one fleet with forty-nine strangers acceptable.
The rejection protocol itself is standardized: 429 says “too many requests in a given amount of time,” Retry-After says when to come back, and caches are forbidden from storing the response — so every layer of the stack, from browser to gateway to retry library, agrees on what a rate limit looks like.
Work the token bucket by hand
Bucket capacity 100, refill rate 10 tokens/sec, fully stocked. A 150-request burst arrives at once, then traffic keeps coming at 20 requests/sec.
Three quantities run every interview number: capacity sizes the burst, refill rate sets the sustainable rate, and deficit-over-rate is the Retry-After.
Three production shapes, in Python
A token bucket, the sliding-window-counter approximation, and the Redis Lua check-and-increment — the three patterns behind almost every “design a rate limiter” interview. The Redis script runs server-side and atomically.
import time
from collections import deque
class TokenBucket:
def __init__(self, capacity, refill_per_sec):
self.capacity = capacity
self.refill_per_sec = refill_per_sec
self.tokens = float(capacity)
self.last = time.monotonic()
def allow(self, cost=1):
now = time.monotonic()
# refill lazily: tokens earned since last request, capped
self.tokens = min(
self.capacity,
self.tokens + (now - self.last) * self.refill_per_sec,
)
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False
def retry_after(self, cost=1):
# seconds until `cost` tokens exist — the Retry-After value
deficit = max(0.0, cost - self.tokens)
return deficit / self.refill_per_sec
class SlidingWindowCounter:
# O(1) memory: two ints + the standard overlap blend.
def __init__(self, limit, window_sec=60):
self.limit = limit
self.window = window_sec
self.prev_count = 0 # finished window
self.curr_count = 0 # in-progress window
self.window_start = time.monotonic()
def allow(self):
now = time.monotonic()
if now - self.window_start >= self.window:
gap = now - self.window_start
# roll windows; a long idle gap discards stale history
self.prev_count = self.curr_count if gap < 2 * self.window else 0
self.curr_count = 0
self.window_start = now
elapsed = (now - self.window_start) / self.window
# approximate count over the last `window` seconds
estimate = self.curr_count + self.prev_count * (1 - elapsed)
if estimate < self.limit:
self.curr_count += 1
return True
return False
# --- distributed: atomic check-and-increment in Redis ---
import redis
r = redis.Redis(host="localhost", port=6379)
# register_script loads once (SCRIPT LOAD) and calls via EVALSHA after;
# the body re-sends only if the server ever answers NOSCRIPT.
check_and_increment = r.register_script("""
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))
end
local allowed = 1
if current > tonumber(ARGV[1]) then allowed = 0 end
return {allowed, current, redis.call('TTL', KEYS[1])}
""")
allowed, count, ttl = check_and_increment(
keys=["ratelimit:login:203.0.113.44"],
args=[100, 60], # limit 100, window 60s
)
if not allowed:
# 429 Too Many Requests, Retry-After: <ttl>
print("rate limited; retry in", ttl)Three shapes, three answers: the token bucket for bursty single-process limits, the sliding-window counter for O(1) boundary-safe counting, and the Lua script when the counter must be shared across a fleet without a race.
Six questions that test the real understanding
What to say (≈90 sec): “I’d start with a fixed window: one Redis key per user, INCR per request, EXPIRE 60s, reject past 100. One integer per key — but it has a boundary trap: 100 requests at 12:00:59 plus 100 at 12:01:01 are 200 in 2 seconds, and the counter never notices. So I’d upgrade to a sliding-window counter: keep the finished and current minute counts, estimate the last sixty seconds as curr plus prev weighted by overlap. Two integers per key, boundary seam gone. Distributed piece: check-and-increment must be one atomic Lua script via EVALSHA — two instances both reading 99 against a limit of 100 both admit. Enforce at the gateway, answer 429 with Retry-After, publish remaining-quota headers.”
Likely follow-up: “Where does the limiter sit?” → At the API gateway, before any backend compute — a rejected request should cost nothing.
The answer that sinks you: “Fixed window with INCR — simple and done.” Why it fails: the interviewer was waiting for the boundary burst; volunteering the trap unprompted is what separates a senior answer from a tutorial recital.
What to say (≈90 sec): “Cousins with opposite temperaments. The token bucket holds capacity B, refills at rate r; each request spends a token. Quiet clients accumulate savings, so a burst up to B is legal while the average stays at r. The leaky bucket flips it: requests queue and drain at a fixed drip — bursty in, smooth out, overflow waits or drops. Token bucket for user-facing API traffic, where legitimate bursts like a flash sale should be absorbed, not punished. Leaky bucket in front of a fragile downstream — a database, a payment rail — that needs a calm, steady drip.”
Likely follow-up: “What should overflow do?” → Drop with 429 — an unbounded queue is latency debt, not kindness.
The answer that sinks you: “They’re the same thing — both rate limit.” Why it fails: the whole point is the burst semantics; saying they’re identical signals you memorized the names without the mechanics.
What to say (≈90 sec): “Naive flow is read-then-increment: GET, compare, INCR if under. The race: server A reads 99 against a limit of 100, server B reads 99 before A’s increment lands — both admit, and 101 get through. Across forty instances the over-admission scales with the fleet. The fix is a Lua script via EVALSHA: Redis runs scripts atomically, so check-and-increment is one indivisible server-side operation. The library loads the script once and sends only its SHA after, so the wire cost is about one INCR. Caveat: atomicity holds per shard, and a failover can briefly lose recent writes — that over-admits rather than under-admits, so the failure mode is a slightly loose limit, not dropped traffic.”
Likely follow-up: “Why not MULTI/EXEC?” → Transactions can’t branch on values mid-flight without WATCH retry loops; Lua does read-decide-write in one shot.
The answer that sinks you: “INCR is atomic, so there’s no race.” Why it fails: INCR alone is atomic — but the limit check is a separate step, and the race lives in the gap between check and increment.
What to say (≈90 sec): “The contract is 429 Too Many Requests — the status RFC 6585 defined for exactly this — plus a Retry-After saying when to come back; RFC 6585 also says 429s must not be cached, which matters with a CDN in front. Good APIs advertise headroom: OpenAI returns x-ratelimit-remaining-requests and x-ratelimit-remaining-tokens, Anthropic returns anthropic-ratelimit-requests-remaining, so clients throttle themselves instead of discovering the cliff. I’d size Retry-After from the deficit, not a constant — a 50-token deficit draining at 10 per second means 5 seconds. And clients should honor Retry-After with jittered backoff: a synchronized retry wave at the window rollover is the burst problem wearing a new disguise.”
Likely follow-up: “Fixed or computed Retry-After?” → Computed from the deficit — the bucket already knows the exact wait.
The answer that sinks you: “Return 429 and let the client retry immediately.” Why it fails: immediate retries are a retry storm — you’ve built a thundering herd and called it a contract.
What to say (≈90 sec): “The log is exact: store every timestamp in a Redis sorted set, evict older than sixty seconds, count the rest — no boundary, no burst. The price is memory linear in traffic: a million-requests-a-minute key carries a million timestamps. The counter keeps two integers — finished and current minute — and estimates the last sixty seconds as curr plus prev weighted by overlap: constant memory, with only a small error about which old requests fall inside the window. Log for precision-critical keys like login lockout, where traffic is low; counter for high-throughput API keys, where O(requests) memory per key is a Redis bill shaped like a hockey stick. State both memory costs, then pick per key class.”
Likely follow-up: “How wrong can the estimate get?” → Roughly one window’s skew at the boundary — a fraction of the previous window, never an order of magnitude.
The answer that sinks you: “The counter is exact enough, so the log is never needed.” Why it fails: “exact enough” without naming the error bound or the use cases where exactness is non-negotiable is hand-waving, not engineering.
What to say (≈90 sec): “That’s a retry storm. Every client slept until the window rollover, then fired simultaneously — reproducing the boundary burst on purpose. Three fixes. First, Retry-After with jitter: spread retries randomly across the window instead of stampeding at one second. Second, exponential backoff with decorrelated jitter on the client — the wave decays instead of resonating. Third, a token bucket instead of a hard window: the refill rate naturally spreads admissions, because tokens only regenerate so fast — there is no cliff edge to synchronize against. And if it’s one tenant, per-tenant quotas: one tenant’s flood must not become everyone’s latency.”
Likely follow-up: “Why jitter?” → Fixed backoffs keep retries synchronized — the herd moves together, just later; jitter breaks the alignment.
The answer that sinks you: “Raise the limit so the batch job succeeds.” Why it fails: the limit exists to protect the system — the tenant’s scheduling problem does not outrank forty-nine other tenants’ latency.
Key takeaways
- Fixed window is simple but bursts at the boundary — 200 requests in 2 seconds against a 100/minute cap, and the counter never noticed.
- Sliding window log is exact but costs O(requests) memory per key; the two-integer counter approximation keeps O(1) memory and kills the boundary seam.
- Token bucket absorbs bursts up to capacity and caps the average at the refill rate; leaky bucket drains at a constant drip and smooths bursts for fragile downstreams.
- Distributed limiters have a check-then-increment race — close it with an atomic Redis Lua script via EVALSHA, not two round trips.
- Enforce at the API gateway, answer 429 with a computed Retry-After, and publish remaining-quota headers so good clients throttle themselves.
- Compute Retry-After from the deficit and jitter the retries — a synchronized retry wave is the boundary burst wearing a new disguise.