The interviewer writes one line on the board: “Design a rate limiter.” It sounds like the easy question. A counter, a limit, a 429. The candidate draws a box, writes “Redis” next to it, and says “token bucket”. Ten minutes later the interviewer asks: “What happens when you have twenty gateways?” and then: “What happens when Redis is down?”
Those two questions are the whole problem. A rate limiter is easy on one machine and a distributed-systems question everywhere else. This lesson walks it in 45 minutes; it is the box the URL shortener put in front of link creation.
- The system design interview framework and rate limiting explained, which covers the algorithms this lesson builds on.
- Database sharding and replication and consistent hashing, for the scaling deep dive.
Why does adding a server with key mod N move most keys? Because changing N changes the answer for most keys. A ring or fixed slots move only a small share. The same idea decides where a limiter’s counters live.
- A limiter must be cheap, correct under concurrency, and unable to take the API down with it.
- A token bucket is the default: two numbers per client, bursts allowed, steady average enforced.
- Counters must be shared and every check must be atomic, or two gateways can both spend the last token.
- At scale, shard counters by client key and put a small local bucket in front.
- Decide the failure mode per route: fail open for most, fail closed for login and creation.
- Answer an over-limit request with
429andRetry-After, so clients wait instead of retrying at once.
Design a rate limiter end to end in 45 minutes: pick an algorithm and defend it, place the counters and make checks atomic, scale the store, and choose what happens when it fails.
Step 1: Pin Down the Problem#
Ask what is being limited, who it is limited by, and what happens to a request that goes over. Write the answers down.
Functional requirements:
- Limit how many requests a client can make in a period, per rule (for example, link creation).
- Reject requests over the limit with
429 Too Many Requestsand tell the client when to retry. - Let an operator change limits without a deploy.
Out of scope (say these out loud):
- Billing quotas over months, bot detection, and network-level attack protection, which belong to other layers.
Ask first, because each answer changes the design: who is limited (user, API key, IP)? Is the limit per route, and do rules change at runtime? Must it be exact, or is a little overshoot fine? One region or many?
Non-functional requirements, each with a number. These are assumptions you announce:
- Fast: assume the check adds about 1 to 2 ms, since it runs on every request.
- Available: if the limiter breaks, the API keeps serving.
- Accurate enough: a few percent of error is fine; exactness costs latency.
- Scale: assume the limiter checks every request, redirects included, so about 12,000 checks a second at peak (the URL shortener’s peak) and 1 million client keys.
Underline the second line: the limiter protects the service, so it must never take it down.
Step 2: Entities and API#
The limiter is internal. The gateway calls it, and clients only ever see the response.
Rule route, key, limit, period
check(key, rule)
-> allowed, remaining, retry_in
Over the limit, the client sees:
429 Too Many Requests
Retry-After: <seconds>One entity and one call. Retry-After matters because a client told nothing retries at once. RFC 6585 (April 2012) defines 429 and says it may carry Retry-After, but not how to identify the user or count requests; that is your design. RFC 9110 makes the seconds form a non-negative whole number, so round up. Envoy’s local filter, with an option that is off by default, sends the seconds until the next token, clamped to at least one.
Many APIs also say how much allowance is left. GitHub’s REST API sends these, per its rate-limit docs:
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4999
x-ratelimit-reset: <UTC epoch s>Standard RateLimit and RateLimit-Policy fields are still an IETF Internet-Draft as of October 2026 (draft 11, 23 May 2026), not an RFC. Name the idea; say it is not final.
The state is a key-value record, read and written once per request, with no range queries, so an in-memory store fits and a SQL database (a disk write per request) does not:
key rl:{client}:route
value tokens, last_refill_ms
TTL capacity / rate secondsAfter capacity / rate idle seconds the bucket would be full anyway, so letting the TTL delete it loses nothing and idle clients cost no memory.
Step 3: A Design That Works#
First, where does the check run? Each place sees a different slice of traffic:
| Where | Good at | Weak at |
|---|---|---|
| Client | saving wasted calls | anyone can skip it |
| Each service | full request context | each copy counts alone |
| Gateway | one place, before work | less context |
| Separate service | one shared answer | an extra network hop |
Pick the gateway: every route passes through it before any real work. A client-side limiter is a courtesy, never the control. Start with one gateway: it finds the client’s bucket in memory, spends a token, and forwards the request or answers 429.
Correct, fast, simple, and where many candidates stop.
You now run twenty gateways behind a load balancer. A client’s requests land on all of them. What limit does the client actually get?
Each gateway counts alone, so a limit of 10 a second becomes up to 200 across twenty. Dividing the limit by twenty does not fix it: a client that hits one gateway is held to a twentieth of its allowance.
Pinning a client to one gateway (sticky sessions) breaks on restarts and rebalancing. The counters must be shared.
Step 4: Estimate What Matters#
Assumptions: 12,000 checks/s peak,
1 million client keys, about
100 bytes per client
(key, tokens, last refill)
Checks: 12,000 /s peak
Memory: 1M x 100 B ≈ 100 MB
Latency: +1 to 2 ms per check
(one round trip in-region)
Network: 12,000 x 200 B
≈ 2.4 MB/sEach line decides something:
- 100 MB of counters fits in one machine’s memory, so size is not the problem.
- At an assumed 12,000 checks a second, one in-memory store is probably enough (check your own limits), but it pushes you to add a local bucket in front.
- 2.4 MB/s of check traffic is nothing, so bandwidth is not the limit; round trips are.
- Assume 1 to 2 ms per check, and treat it as the real cost. Pool connections (no handshake per check) and keep work local.
Cross-check: NGINX’s limit_req stores 128 bytes per key on 64-bit platforms, about 8 thousand per megabyte, so a million keys is 125 MB, the same order as our 100 MB. When its zone is full it drops the least recently used state and that client starts over, so size for the keys you expect.
Step 5: Deep Dives#
Four questions decide this design: the algorithm, where the counters live, what happens when they fail, and who is limited. Senior answers pull ahead on the production questions after them, which the follow-ups lesson collects.
Which Algorithm?#
The earlier lesson compares four algorithms. Here the question is which one survives contact with many servers.
Count requests per client per window and reject past the limit. Redis’s rate limiter pattern is a counter per client per second, incremented with an expiry.
Each client has a bucket with a capacity and a refill rate. A request spends a token; an empty bucket means 429. Store two numbers, the tokens left and the last refill time, and compute the refill on arrival.
Each gateway spends from a small local token bucket, then asks the shared store only for requests that pass it. Envoy’s documentation describes this two-stage shape: a local token bucket absorbs very large bursts that might otherwise overwhelm a global rate limit service, then a fine-grained global limit finishes the job.
Fixing the Window Boundary#
| Algorithm | State per key | Bursts | Weak spot |
|---|---|---|---|
| Fixed window | one counter | up to 2x at a boundary | the boundary |
| Sliding log | a timestamp per request | none, exact | memory grows with the limit |
| Sliding counter | two counters | smoothed estimate | assumes even spread |
| Token bucket | tokens + time | up to capacity | the burst is real load |
| Leaky bucket | queue + time | none, output is steady | queued requests wait |
A log of 1,000 requests an hour is up to 1,000 timestamps per client (assumption). A sliding counter estimates previous × overlap + current, and Cloudflare’s 2017 write-up gives 42 × (45/60) + 18 = 49.5 and reports 0.003% of 400 million requests wrongly allowed or limited: a measurement on their 2017 traffic, not a general error bound.
A Token Bucket, Worked#
capacity 10, refill 5 per second
idle 2 s: min(10, 0 + 5 x 2)
= 10 tokens
12 requests at once:
10 allowed, 2 get 429
next token in 0.2 s, so
Retry-After rounds up to 1The formula is tokens = min(capacity, tokens + rate × seconds). Elapsed time is all you need; no timer runs per client.
Where Do the Counters Live?#
The counters are data, and the same questions apply: one copy or many, one server or sharding across several servers, and what happens when one is lost.
No network, no shared state, and the check costs almost nothing. Envoy’s local rate limiting applies its limit per Envoy process by default.
Every gateway asks one counter store, such as Redis. The check must be a single atomic step, because a read followed by a write has a race:
bucket has 1 token
A reads 1 B reads 1
A writes 0 B writes 0
-> both allowed, 1 token spentRedis guarantees a Lua script’s atomic execution: its effects either have yet to happen or had already happened. Put the whole check (read, refill, spend, save) in one script.
- Take the time from the store’s own clock, so gateways with different clocks cannot disagree about the refill.
- Redis documents a leak in the plain
INCRpattern: a client that dies beforeEXPIREleaves a key that never expires. A script makes both one step.
When One Store Is Not Enough#
Split the counters across several stores by hashing the client key, so each client always lands on the same shard. A ring or fixed slots (consistent hashing) avoid moving most keys when you add a shard. Redis Cluster, for example, splits the key space into 16,384 hash slots and moves whole slots between servers.
Give each shard a replica for failover, and keep the local bucket from the first deep dive in front so most requests never leave the gateway.
What Happens When the Limiter Is Down?#
This is the question that separates senior answers. A limiter that fails can either let everything through or stop everything.
If the check errors or times out, allow the request and alert.
If the shared store is unreachable, use the gateway’s local bucket. It still stops a flood from one client.
Fail open or fall back for reads and ordinary calls. Fail closed for sensitive routes such as login and link creation, where unlimited attempts are the very risk the limiter exists to stop.
Real gateways make this a setting. In Envoy’s global rate limit filter, failure_mode_deny: true returns a 500 when the limit service fails; the API reference shows a plain boolean, so unset means fail open, with a 20 ms default timeout. Stripe’s 2017 write-up also argued for failing open.
Who Is Limited, and What Do They See?#
Simple and needs no login.
Authenticated traffic is limited by key or user ID, anonymous traffic by IP with a tighter limit.
Key on user and route together, so heavy use of one endpoint does not exhaust the rest, and give each plan its own limits from a rules store that the gateways reload without a deploy.
When several rules match (per user, per route, per IP), check them all and let the most restrictive decide. On Redis Cluster one script can touch several keys only if they share a slot; a hash tag such as {user42} in each key name keeps one user’s buckets together.
A hot key is a client that sends far more than the rest:
- An empty local bucket rejects without asking the store, so the shard is spared.
- Abusive sources go on a short-lived blocklist at the edge, before any counter is touched.
- A legitimate heavy user gets a bigger plan, not an exception.
Clients have a part too. GitHub’s guidance: wait for retry-after, else until x-ratelimit-reset, else at least a minute, then back off exponentially. Add jitter so clients do not retry in the same second.
The Final Design in One Picture#
One request: the gateway spends a local token, asks the shard that owns the key to run the atomic check, then forwards the request or answers 429 with Retry-After. If the shard is unreachable it uses the local bucket or rejects, by route.
Gateways load limits from a rules store, by polling (simple, slower) or push (instant, one more system), and keep the last good rules in memory.
What Comes After the Core Design#
The follow-up questions (what breaks, what is abused, what it costs) go deeper than the core answer. They live in Design a rate limiter: failure modes, security and follow-ups.
| Follow-up | Where to read it |
|---|---|
| Each part dies, forged client addresses, DDoS | Failure modes and security |
| Reject, delay or shed, cost-based LLM limits, several regions | Policy and scale |
| Shadow-mode rollout, the 45-minute plan, probe questions | Operations and the round |
What Each Level Should Show#
| Level | Expected in the room |
|---|---|
| Mid-level | A token bucket, a counter in Redis, 429 on over-limit. The interviewer may raise the multi-server problem. |
| Senior | Raises the race condition and atomic scripts, shards by client key, places a local bucket in front, and picks a failure mode per route. |
| Staff+ | Approximate versus exact trade-offs, replica lag, hot clients, rules rollout and testing, cost-based LLM limits, and what to build first. |
Red Flags and Green Flags#
“I’ll keep a counter in memory and check it on each request.” Nothing about several gateways, races, or what happens if the store is down.
“One gateway is trivial, so the interesting parts are shared counters and atomic checks, then what happens when the store fails. I’ll fail open for reads and closed for login.”
More red flags: limiting by IP only, a fixed window with no word on its boundary, no Retry-After, and failing closed everywhere. More green flags: every number comes with what it drives, and every component gets “what if it dies?”
Spending fifteen minutes on token bucket versus leaky bucket and never reaching the distributed part. The interviewer is waiting for “what happens with twenty gateways?”
Try It: Limit Login Attempts#
Change one variable: the route is login, and the rule is 5 attempts per 15 minutes per account. Decide:
- What is the key?
- Which algorithm and what numbers?
- What happens if the limiter is down?
- What does a blocked user see?
Hint
Fifteen minutes divided by five attempts gives the refill interval. Then ask what an attacker gains if the limiter fails open.
One reasonable answer
Key: account id, plus IP
Bucket: capacity 5
Refill: 1 per 3 min
(15 min / 5 attempts)
Burst: 5 tries at once
Down: fail closed on login
Reply: 429 + Retry-AfterKey on the account, not only the IP, or an attacker rotates addresses. Fail closed: with the limiter down, unlimited password guesses are the risk, so refuse logins briefly. Tell the user how long to wait, and alert on spikes of blocked accounts.
Check Yourself#
1.Why can a fixed window let through twice the limit?
A client can spend a full allowance at the end of one window and another at the start of the next, so up to twice the limit lands around the boundary.
2.Two gateways each read a bucket with one token left and both allow a request. What went wrong and how do you fix it?
Read and write were separate steps, so both saw the same token. Make the check one atomic step, such as a Lua script in Redis.
3.The counter store is down. What do you do for reads and for login?
Reads: fall back to a local limit or fail open. Login: fail closed, because unlimited attempts are the risk. Alert in both cases.
In One Picture#
Requirements
limit per key, 429 + retry,
rules without a deploy
Scale
12,000 checks/s · 100 MB
Algorithm
token bucket, two numbers
Counters
shared, atomic, sharded
Local bucket
absorbs bursts first
Failure
open or local; closed for
login and creation
Operations
shadow new rules first (see
the follow-ups lesson)Conclusion#
A rate limiter is a small data structure wrapped in a hard question: where do the counters live, and what happens when they cannot be reached? Share them, check atomically, shard by client, keep a local bucket in front, and choose the failure mode per route. That is the 60-second answer.
Which failure mode would you defend first in the room? Tell us in the comments.
- Production depth: failure modes, security and follow-ups covers what breaks per part, forged addresses, reject versus shed, cost-based limits, regions and rollout.
- Back to the algorithms: rate limiting explained compares token bucket, leaky bucket and windows in depth.
- The routing behind sharded counters: consistent hashing and database sharding and replication.
- Where the limiter sits: API gateways and load balancers.
- Coming next in Module 4: design a news feed.

