Design a Rate Limiter
Design a distributed API rate limiter: rules per user and per IP, a shared counter store with atomic updates, races, and failing open or closed.
On this page
A rate limiter sits in front of an API and says "no" to clients that send too much. Every request asks "may this one through?", and the answer must come back within a millisecond or two, from any of dozens of gateway servers, which all have to agree on how many requests each client has made.
Interviewers like it because it is small enough to finish and still touches shared state, atomic updates, races, clocks, and what to do when a dependency dies. The counting algorithms themselves are covered in Rate Limiting; this case study is about building the whole service. It follows the steps in A Framework for Any Design.
Clarify the problem
"Design a rate limiter" can mean a class in one process or a service for a whole company. Find out which before you say "token bucket".
Design a rate limiter for our public API.
Is this a limiter inside one service, or a shared one that protects every endpoint across many gateway servers?
Shared. We run a fleet of API gateways, and every request passes through one of them.
What do we limit by: API key, user account, IP address? And do different endpoints or plans get different limits?
Per API key for logged-in traffic, per IP for anonymous traffic. Plans have different limits, and login has its own strict limit.
And the scale: how many requests a day, and how many distinct clients?
About a billion requests a day, from around 10 million API keys and a similar number of IPs.
Then my priorities are: the check adds under 2 ms at the 99th percentile, a limiter outage must not take the API down, and limits can be a few percent loose but never ten times loose. I'll leave out billing and usage dashboards.
- Functional: limit requests per API key, per IP and per account, with rules that vary by endpoint and plan; reject excess requests with
429and say when to retry; let operators change rules without a deploy. - Non-functional: under 2 ms added at p99; the API stays up if the limiter is down; counts are shared across all gateways, accurate to within a few percent; rule changes apply within about 10 seconds.
- Out of scope: billing, usage dashboards, bot detection beyond simple limits.
Estimate the scale
Turn the daily number into rates. A day has 86,400 seconds.
- Average: 1 billion ÷ 86,400 ≈ 11,600 requests a second; call it 12,000.
- Peak: assume 3x the average, so 36,000 a second.
- Counter operations: a typical request is checked against two rules (its API key and its IP), so the counter store sees 2 × 36,000 = 72,000 operations a second at peak.
How many counter operations per second must the shared store handle at peak?
- 1 billion API requests a day
- Peak is 3x the average
- Each request is checked against 2 rules, one store operation each
Show the working
- Average: 109 ÷ 86,400 s ≈ 11,600, call it 12,000 a second
- Peak: 12,000 × 3 = 36,000 a second
- Store operations: 36,000 × 2 = 72,000 a second
Estimate 72 thousand/s (anything from 30 thousand/s to 200 thousand/s is a good answer)
One in-memory node does on the order of 100,000 simple operations a second, but each check here runs a small script. Plan for about 50,000 a node and shard across 4 for headroom.
How much memory do the counters need if every client is active at once?
- 10 million API keys and 10 million IP addresses
- One token bucket per key or IP
- About 100 bytes per bucket, including the key and the store's overhead
Show the working
- Buckets: 10 million + 10 million = 20 million
- 20 × 106 × 100 B = 2 × 109 B = 2 GB
Estimate 2 GB (anything from 500 MB to 10 GB is a good answer)
Small. Memory is not the problem; operations per second and latency are.
There are three interfaces here: what the API client sees, what the gateway asks the counter store, and how operators change rules. Sketch each before reading on.
Compare with a model answer
Clients see a 429 with Retry-After and headers showing what is left. The gateway makes one atomic "take a token" call per rule. Operators write rules to a config store that gateways reload. The decision to argue is 429 versus 503.
Define the API
Every response from the API:
X-RateLimit-Limit: 100 bucket size for the tightest rule
X-RateLimit-Remaining: 36 tokens left after this request
Rejected request:
429 Too Many Requests
Retry-After: 1 seconds until a token is available
body: { "error": "rate_limited", "rule": "key-free-plan", "retry_after_s": 1 }
Gateway to counter store, one atomic call per rule:
take(key, capacity, refill_per_sec, cost = 1)
-> { allowed, remaining, retry_after_ms }
Operators:
PUT /admin/rules/{rule_id} body: the rule (see the data model)
400 if the rule is invalid; gateways pick up changes within 10 sThe decision worth arguing is the status code. 429 means "you, this client, sent too much"; 503 means "we are overloaded". Use 429 for limits, because a well-behaved client reads Retry-After and slows down. Keep 503 for load shedding, when the whole service is struggling regardless of who is asking. Mixing them up makes clients retry at the wrong times.
What records exist, how big are they, who reads them, and how often? Pick a kind of store for each.
Compare with a model answer
Rules: a few hundred records, read constantly, changed rarely, so every gateway holds them in memory. Counters: 20 million tiny records written on every request, so an in-memory key-value store with atomic scripts and expiry.
Model the data
rule
rule_id "key-free-plan"
match endpoint pattern and plan, e.g. "/v1/*", plan = free
key_by api_key | ip | account
capacity 100 burst size
refill_per_sec 10 long-run rate
on_store_down open | local | closed
counter key = "rl:{rule_id}:{subject}" e.g. "rl:key-free-plan:k_8f3a"
tokens float, 8 bytes
last_refill_ms integer, 8 bytes
TTL capacity / refill_per_sec = 100 / 10 = 10 sRules are a few hundred records in any durable config store. Gateways poll them into memory every 10 seconds, so no request waits on them.
Counters are the opposite: 20 million records of about 100 bytes, updated on almost every request, needed in about half a millisecond. That calls for an in-memory key-value store (such as Redis) that can run a small script atomically and expire keys. Durability barely matters: losing a counter just gives one client a fresh bucket.
The TTL is a neat trick. A bucket idle for capacity ÷ rate = 10 seconds is full again, and a missing key counts as full, so expiry changes no answer and frees memory.
Sketch where the check happens, where counters and rules live, and what a rejected request touches.
Compare with a model answer
The limiter is a library inside each gateway, so the only extra hop is to the counter store. Rejected requests never reach the backend.
High-level design
Run the limiter as a library in every gateway node. A separate limiter service would add a network hop to every request and one more fleet to keep alive. The counter store is split into 4 shards by key, each with a replica.
request and replyasynchronous message
Step through an allowed request, a rejected burst and a shard dying.
- tokens
- 37
- allowed
- 0
- rejected
- 0
1A free-plan client with key
k_8f3acallsGET /v1/orders. The gateway already holds the rules in memory: per key, capacity 100, refill 10 a second.- tokens
- 36
- allowed
- 0
- rejected
- 0
2One atomic call to the shard that owns
rl:key-free-plan:k_8f3a: refill for the time passed, then take a token. About 0.5 ms round trip.- tokens
- 36
- allowed
- 1
- rejected
- 0
3Allowed. The gateway forwards the request and adds
X-RateLimit-Remaining: 36to the response.- tokens
- 0
- allowed
- 38
- rejected
- 0
4A buggy loop in the client fires 80 requests within a tenth of a second. 37 pass: the 36 tokens left, plus 1 that refills in that 0.1 s.
- tokens
- 0
- allowed
- 38
- rejected
- 43
5The other 80 − 37 = 43 get
429withRetry-After: 1. The backend never sees them; protecting it is the whole point.- tokens
- 0
- allowed
- 38
- rejected
- 50
6The next token arrives in 1 ÷ 10 = 0.1 s, so the gateway remembers "k_8f3a denied for 100 ms" in its own memory. The loop's next 7 requests are rejected without calling the store.
7An operator raises the free plan to 200. Gateways pick it up on their next 10-second poll; no request waits.
8Now the shard holding this key dies. The call times out after 5 ms. The rule says
on_store_down: local, so the gateway falls back to an in-memory bucket with its share of the limit.9Requests within the local limit go through. The API stays up, roughly limited, while the replica takes over.
Deep dives
Interviewers push on four places: the algorithm, the shared counter, store failure, and the key you limit by.
Choosing the algorithm per rule
Pick the algorithm per rule, by what the rule protects. Rate Limiting explains how each one counts.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Token bucket Usual default | Allows short bursts, holds the long-run rate. Two numbers per key, one atomic script. | A full bucket lets a whole burst through at once. | General per-key and per-IP API limits. My default here. |
| Sliding window log | Exact count over the window. | One timestamp per request per key, so memory grows with the limit. | Small limits that must be exact: 5 login attempts per account per 15 minutes. |
| Fixed window counter | One integer and an expiry. Cheapest possible. | Up to twice the limit across a window edge. | Coarse quotas where the edge does not matter: 100,000 calls a day. |
Say it like this: "Token bucket for the API limits, because clients legitimately burst when a page loads. A sliding log for login, because the limit is 5 and I want it exact. A daily quota is a fixed window with a 24-hour expiry." What would change my mind: if the backend cannot absorb bursts at all, a leaky bucket that queues requests fits better.
A shared counter with atomic updates
Twenty gateways each counting alone would let a client spread across them through up to twenty times its limit. So the count lives in one place per key: the shard of the counter store that owns that key, chosen by consistent hashing so adding a shard moves only a small share of keys.
The danger is splitting the check from the update. Here is the race, with one token left and two gateways.
- tokens
- 1
- allowed
- 0
1The bucket for
k_8f3aholds 1 token. Two requests from the same client arrive at gateways A and B in the same millisecond.- tokens
- 1
- allowed
- 0
2Naive version: A reads the count. 1 token, so A decides to allow.
- tokens
- 1
- allowed
- 0
3Before A writes back, B reads too. It also sees 1 token and also decides to allow.
- tokens
- 0
- allowed
- 2
4Both write
tokens = 0and both requests pass. Two requests spent one token. Under heavy concurrency this overshoots by as many requests as are in flight at once.- tokens
- 0
- allowed
- 1
5The fix: send the whole refill-check-take as one script that the shard runs to completion before starting another. A's script runs first and takes the token.
- tokens
- 0
- allowed
- 1
6B's script runs next, sees 0 tokens and rejects. One token, one request. No locks were needed, because the shard runs scripts one at a time.
take(key, capacity, rate, cost):
now = store's own clock, in ms
tokens, last = read key (missing key: tokens = capacity, last = now)
tokens = min(capacity, tokens + (now - last) / 1000 * rate)
if tokens >= cost:
tokens = tokens - cost
allowed = true
else:
allowed = false
write key = (tokens, now), expire after capacity / rate seconds
return allowed, floor(tokens), time until tokens >= costTwo details show you have built one. First, the script uses the store's clock, not the gateway's: gateway clocks drift apart by milliseconds, and if A's clock is behind B's, now - last goes negative and the refill math breaks. Second, do not retry a timed-out take. The store may have taken the token before the reply was lost, so a retry would charge the client twice. Treat a timeout as "store down" for that one request.
Say the cost of replication out loud. Each shard copies to its replica asynchronously, so if a primary dies, the last few hundred milliseconds of decrements are lost: a few extra requests, once, which fits "a few percent loose".
Failing open or closed
The counter store will be unreachable sometimes. Decide in advance, per rule, what the gateway does then.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Fail open | A limiter outage never becomes an API outage. | No protection until the store is back; one abusive client can hurt everyone. | Low-risk endpoints where availability matters most and the backend has headroom. |
| Fall back to local limits Usual default | Still available, still roughly limited. Each of 20 gateways allows 1/20 of the limit in its own memory. | Only right if the load balancer spreads a client evenly; a client pinned to one gateway gets 1/20 of its limit. | General API limits. My default. |
| Fail closed | No request skips the limit, ever. | A store outage rejects every request on that endpoint. | Login, password reset, SMS sending: endpoints where an unlimited attacker costs more than a short outage. |
The arithmetic for the fallback: the free plan refills 10 a second, so each of 20 gateways allows 10 ÷ 20 = 0.5 a second for that key. A client spread evenly gets about 20 × 0.5 = 10 a second in total, the same as normal. That is why on_store_down is a field on the rule, not a global switch.
Per-user and per-IP rules together
A request can match several rules: the IP rule, the API key rule and an endpoint rule. Check them all, allow only if every one allows, and return the longest Retry-After. Check the cheapest, broadest rule first (per IP) so a flood is rejected before it costs a second store call.
- IP alone is a poor identity. An office or a mobile carrier can put thousands of users behind one address. Use IP for anonymous traffic and floods, and the key or account once known.
- IPv6 needs a prefix. One home is often given a whole block of addresses (commonly a /64), so limit by that prefix, or a single client can rotate through endless addresses.
- Login needs two keys. Limit per IP (one attacker trying many accounts) and per target account (many IPs trying one account). Either alone misses one attack.
Bottlenecks and failures
At 10x (360,000 requests a second at peak), the counter store sees 720,000 operations a second. At about 50,000 per shard that is roughly 15 shards; run 16. Consistent hashing means going from 4 to 16 moves keys gradually, and a moved key simply starts as a full bucket. The gateways scale by adding nodes, since they hold only rules and a small deny cache.
The first thing to break is usually a hot key: one abusive client sending 50,000 requests a second lands every check on one shard. The local deny cache from the tracer handles this: after the first rejection, that gateway stops asking the store until the next token is due. Add a local pre-limit per IP in each gateway to drop obvious floods before they reach the store at all.
The deny cache does not help a big customer whose plan allows 20,000 requests a second, because those requests are allowed. For such keys, let each gateway take tokens in batches: one call takes 50, and the gateway spends them locally. Store calls drop 50-fold; the cost is that a gateway can hold a few unspent tokens when the limit is hit.
| When this dies | What happens | What you do |
|---|---|---|
| A gateway node | Its in-flight requests fail. | The load balancer removes it. Counters are shared, so no limit state is lost. |
| A counter shard primary | Checks for its keys time out until the replica is promoted, a few seconds. | Each rule's on_store_down applies meanwhile. Recent decrements may be lost, so some clients get a few extra requests. |
| The whole counter store | No shared counts. | Local limits for most rules, fail closed for login. Alert, because limits are now approximate. |
| The rules store | No rule changes. | Gateways keep the last rules they loaded. Nothing on the request path notices. |
| Network slow to the store | Checks blow the 2 ms budget. | A 5 ms timeout, then the fallback. After a few timeouts, stop calling that shard for a few seconds (a circuit breaker), so requests do not each pay 5 ms. |
What a strong answer sounds like
Most candidates know the token bucket. Levels differ on many servers, races and failure.
| Level | What the answer does |
|---|---|
| Junior | Explains one algorithm well, in one process. Needs prompting to notice that many gateways each counting alone multiplies the limit. |
| Mid | Puts counters in a shared store, makes the check atomic, returns 429 with Retry-After, and estimates store load. |
| Senior | Picks the algorithm per rule, chooses fail open or closed per endpoint with reasons, uses the store's clock, refuses to retry a timed-out take, handles hot keys with a local deny cache, and says how loose the limits get during failures. |
Your counter store is down. What happens to the login endpoint?
Login fails closed. If the limiter is down and login is unlimited, an attacker can try passwords as fast as our servers answer. A few minutes of failed logins is the smaller harm. The rest of the API falls back to local limits at one twentieth of the limit per gateway, so it stays up and roughly limited.
Isn't rejecting every login a bad experience?
It is, so I'd soften it: fall back to a strict local limit, say 1 attempt per IP per minute per gateway, instead of rejecting outright. Users can still log in slowly, and an attacker gets almost nothing.
A weak answer: "We'd make the store highly available so it never goes down." Every dependency goes down; the interviewer wants to hear what you do then.
Follow-up questions
How do you limit across several regions?
A store shared across regions puts a round trip of tens of milliseconds on every request. Instead, give each region its own counters and split the limit by each region's share of traffic, or sync regional counts every second and accept a small overshoot.
How would you limit by cost instead of by request?
Pass cost to the take call: a bulk export takes 10 tokens, a read takes 1. If the cost is only known after the request finishes, take an estimate up front and charge the difference afterwards.
How do you roll out a new, stricter limit safely?
Run it in shadow mode first: the gateway evaluates the rule and logs who would have been rejected, but lets them through. After a few days of logs you know which customers it hits, and can warn them before enforcing.
Why not just put the limit in the load balancer?
Many load balancers can limit per IP, a good first layer against floods, but they do not know the API key or plan. Use both layers.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".
Before drawing
Numbers and interfaces
Design and depth
Failure
What to practise next
The counter store here is a small, specialised key-value store. Build a general one, with replication and quorums, in Design a Key-Value Store.