Courses / System Design Interview / Chapter 16
Module 2 · Building Blocks · Chapter 16 of 39
Rate Limiters
Builds on Load Balancers, Distributed Caches and Message Queues. Next up: Blob Stores.
Part 1 · Why Limit at All
What a rate limiter protects
It is 10 am, and the tatkal window opens on a railway booking site. A student in a Pune hostel taps Book for a seat home for Diwali, along with lakhs of other people. So do a few hundred scripts on rented servers, each firing hundreds of requests a second. Within a minute the booking service is buried in bot traffic, the login service has sent thousands of billed OTP messages to numbers the scripts typed in, and most real passengers stare at a spinner until the seats are gone. Nothing crashed. The site served whoever asked fastest, and a rate limiter is what changes that.
A rate limiter caps how many requests a client may make in a period, such as 3 OTP requests per 10 minutes per phone number. Requests over the cap are rejected, or sometimes delayed, before they cost anything. The tatkal morning shows all four reasons to have one:
- Abuse: scripts grabbing seats, guessing OTPs or scraping seat availability.
- Fairness: one heavy client, whether a bot or a partner travel app, should not crowd out everyone else.
- Cost: every OTP message and every call to a paid third-party API is billed.
- Protection: the booking service can take only so much load, and a limit keeps it there during a rush or a retry storm.
Each reason leads to a different limit, and every limit starts with one decision: who counts as one client?
Who counts as one client
A limit is only as good as its key, the identity it counts against. Here are limits the booking site might set, each with the key that makes it work:
| Example limit | Key | Main reason |
|---|---|---|
| 3 OTP requests per 10 minutes | Phone number | Cost and abuse |
| 2 booking attempts per minute | User id | Fairness at the rush |
| 1,000 calls per minute | Partner API key | Fairness between partner apps, pricing tiers |
| 20 availability searches per second | IP address or IPv6 prefix | Scraping |
| 2,000 bookings per second in total | The whole booking service | Protecting the service itself |
The numbers are illustrative; you pick yours from real traffic. A user id works for logged-in calls and an API key for partners. An IP address is the fallback for anonymous traffic, and it is blunt in both directions.
It is too coarse because many people share one address: a whole hostel or office sits behind one public IP, and mobile networks put thousands of phones behind a few. It is too fine because of IPv6: a phone or server usually controls a whole /64 block, about 18 quintillion addresses, and can use a new one for each request. Key IPv6 traffic on its /64 prefix, never on the full address.
An endpoint that sends an SMS deserves a far tighter limit than one that reads cached availability. Some systems give each request a cost, such as 1 token for a search and 10 for a booking, and charge both to the same budget.
Where the limiter sits
With the key chosen, the next question is where to count. A limit can be enforced at several points between the passenger's phone and the database. Earlier is cheaper, because a rejected request does no work, but later points know more about what a request will cost.
| Where | Good at | Weak at |
|---|---|---|
| Client | Debouncing taps, backing off on 429 | Not a control: anyone can bypass it |
| Edge or CDN | Absorbing floods before they reach you | Knows little beyond the IP and the path |
| API gateway | One place for per-user and per-key limits | Cannot see what a request costs inside |
| Inside the service | Limits by operation, tenant or cost | The work has already reached your servers |
| Before a dependency | Staying under a partner's quota, such as an SMS provider's rate | Must queue or fail requests once the quota runs out |
The gateway is the usual home: it sits behind the load balancer (the load balancer chapter) and in front of every service, so one component enforces the rules for all of them. Most real systems still limit in layers: a coarse per-IP limit at the edge stops floods cheaply, a per-user limit at the gateway keeps things fair, and a per-operation limit inside the service guards expensive work such as sending OTPs.
Behind a load balancer or CDN, every request arrives from the proxy's address, and the client's IP travels in the X-Forwarded-For header, with each proxy appending the address it saw. Anyone can send that header with made-up values, so a limiter that trusts its first entry hands an attacker a fresh limit on every request. Read it from the right, skip the addresses of your own load balancers and CDN, and use the first address that is not yours.
Part 2 · Algorithms and Concurrency
Token bucket
Once the limiter has a place, it needs a rule for counting. A common default is the token bucket: think of a jar of tickets that refills slowly. Each client has a bucket that holds up to capacity tokens and refills at rate tokens a second. Every request takes one token, and a request that finds the bucket empty is rejected.
The two numbers mean different things. Capacity is the burst a client may make at once; rate is the long-run average it may sustain. With capacity 10 and a rate of 1 a second, a passenger can fire 10 requests together, then 1 a second after that.
import time class TokenBucket: def __init__(self, capacity, refill_per_sec): self.capacity = capacity self.rate = refill_per_sec self.tokens = capacity self.last = time.monotonic() def allow(self): now = time.monotonic() # add the tokens earned since the last call, never above capacity self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens >= 1: self.tokens -= 1 return True return False # the caller returns HTTP 429
No timer refills the bucket: each call adds the tokens earned since the last one. Per client you store just two numbers, the token count and a timestamp.
Here is user 42's bucket, with capacity 3 and a refill of 1 token a second, meeting a burst at 10:00.
The bucket holds 3 tokens and gains 1 token every second
Read it as steps
One passenger's app, the API gateway holding their bucket, and the booking service behind it.
The burst of three went straight through, the fourth request was turned away without touching the booking service, and one second later exactly one more fitted. Capacity sets the burst, rate sets the average, so tune them separately.
Leaky bucket and fixed windows
A token bucket lets bursts through. Sometimes you want the opposite. A leaky bucket turns any input into a steady output: requests join a queue of fixed size and leave it at a constant rate, like water dripping from a hole. A burst waits in the queue, and when the queue is full, new requests are dropped. Some texts use the name for a version that only counts and rejects; that one behaves much like a token bucket.
That makes it the right shape in front of something that needs an even rate. If the SMS provider accepts 50 messages a second, a leaky bucket in front of it sends at most 50 a second however unevenly the 10 am logins arrive. The cost is delay: a message at the back of a queue of 500 waits 10 seconds. Side by side:
| Token bucket | Leaky bucket | |
|---|---|---|
| Bursts | Allowed, up to the capacity | Smoothed into a steady stream |
| Extra requests | Rejected at once | Queued, then dropped when the queue is full |
| Latency added | None | Up to the queue length ÷ the rate |
| Best for | APIs where short bursts are normal | Feeding a system that needs an even rate |
The token bucket suits APIs; the leaky bucket suits feeding a fixed-rate dependency. Simpler still is a fixed window counter: count each client's requests per calendar minute in a key such as rl:42:10:05, reject once the count passes the limit, and let the key expire when the minute ends.
A fixed window resets on the clock, not on the client's behaviour. A client can send 100 requests at 10:05:59 and another 100 at 10:06:00. Both windows are within the limit, yet 200 requests pass in about a second against a limit of 100 a minute.
Sliding windows
Sliding windows fix the boundary problem by asking about the last 60 seconds from now, not about the current calendar minute. A sliding window log stores a timestamp for every request; on each new request it drops timestamps older than 60 seconds and counts the rest. It is exact, but memory grows with the limit: 1,000 requests a minute for each of a million users is up to a billion timestamps, 8 GB before any overhead.
A sliding window counter keeps only two counters, this minute's and last minute's, and assumes last minute's requests were spread evenly. The estimate is the current count plus the previous count, weighted by how much of the previous minute still lies inside the last 60 seconds.
limit: 100 per minute now: 10:01:15, a quarter into the minute previous minute (10:00): 80 requests current minute (10:01): 30 requests so far estimate = 30 + 80 × (1 − 0.25) = 30 + 60 = 90 → under 100, allow
Two small counters per client, and no 2× burst at the boundary.
Now send the boundary burst from the previous page to both kinds of limiter at once.
Both limiters allow 100 requests a minute. At 10:00:59 the script sends 100
Read it as steps
One script, two limiters with the same limit of 100 a minute, two services behind them.
Half a second into the new minute, 59.5 of the previous minute's 60 seconds still lie inside the sliding window, so its 100 requests count as 100 × 59.5 ÷ 60 ≈ 99, and at most one more request fits. The estimate errs a little either way when traffic within a minute is uneven, but for two counters per client it is a good trade.
Choosing an algorithm
With five algorithms on the table, the choice comes down to memory, accuracy and how each treats bursts:
| Algorithm | Memory per client | Bursts | Accuracy | Good for |
|---|---|---|---|---|
| Token bucket | 2 numbers | Up to the capacity | Exact for its rule | Most APIs |
| Leaky bucket | A queue | Smoothed out | Exact output rate | Feeding a fixed-rate dependency |
| Fixed window | 1 counter | Up to 2× at a boundary | Rough at the edges | Simple quotas, daily limits |
| Sliding window log | 1 timestamp per request | None beyond the limit | Exact | Small limits where precision matters, such as logins |
| Sliding window counter | 2 counters | Boundary burst mostly removed | Approximate; assumes even traffic | High-volume per-user limits |
Memory per client2 numbers
BurstsUp to the capacity
AccuracyExact for its rule
Good forMost APIs
Memory per clientA queue
BurstsSmoothed out
AccuracyExact output rate
Good forFeeding a fixed-rate dependency
Memory per client1 counter
BurstsUp to 2× at a boundary
AccuracyRough at the edges
Good forSimple quotas, daily limits
Memory per client1 timestamp per request
BurstsNone beyond the limit
AccuracyExact
Good forSmall limits where precision matters, such as logins
Memory per client2 counters
BurstsBoundary burst mostly removed
AccuracyApproximate; assumes even traffic
Good forHigh-volume per-user limits
For most API limits, pick the token bucket. It allows the short bursts real apps make, such as a screen that fires five calls when it opens, and still caps the average. Use a sliding window counter for a plain "N per minute" rule without boundary bursts, and a leaky bucket when the thing you protect needs a steady rate.
Fixed windows are still fine for long quotas, such as 10,000 partner calls a day, where a burst at midnight hardly matters. The sliding log is worth its memory only when limits are small and exactness matters, like 3 OTPs in 10 minutes.
Pair a short window with a long one: 10 a second stops bursts, and 1,000 a day caps total use. Each answers a different question, and a client must pass both.
Limiting work in flight
Every algorithm so far counts requests per second. Some endpoints hurt because of how long each request runs instead: a partner's export of a month of bookings that takes 30 seconds, a bulk upload, a large report. Ten such requests a second are harmless at 100 ms each and crushing at 30 seconds each. A concurrency limit caps how many requests a client may have in progress at once.
The link between the two is Little's law: requests in flight = arrival rate × time each one takes. At 10 exports a second and 30 seconds each, 300 run at once. A per-partner limit of, say, 5 exports in progress keeps any one partner from holding every export worker, whatever its request rate.
Count it with one entry per running request: add the request id with its start time when it begins, remove it when it ends, and before admitting a new one, drop entries older than the longest allowed request and count the rest. A plain counter would leak a slot every time a request crashed before finishing.
Concurrency limits protect your own services too. A cap on calls in flight to one dependency, called a bulkhead, stops a single stuck dependency, such as a slow payment gateway, from tying up every thread. It reacts the moment latency rises, while a rate limit notices only when the request count changes.
Part 3 · One Limit Across Many Servers
One limit, many gateways
This is the hardest part of building a rate limiter: a limit means something only if every gateway enforces the same count.
So far each limiter lived in one place. With one gateway, the bucket can live in its memory. The booking site runs 10 gateway instances behind a load balancer, though, and each sees only about a tenth of a passenger's requests, so in-memory counters would let a script make up to 10 times the limit. The count has to be shared, and there are three ways to share it:
| Approach | Accuracy | Cost per request | Notes |
|---|---|---|---|
| Sticky routing: hash each user to one gateway | Exact while instances stay the same | None | Uneven load; counts reset when instances change |
| Shared store: counters in an in-memory store | Exact, if updates are atomic | One round trip, under 1 ms | The usual choice |
| Local counts, synced every few hundred ms | Approximate; can overshoot briefly | None on the request path | Good for very high volume |
AccuracyExact while instances stay the same
Cost per requestNone
NotesUneven load; counts reset when instances change
AccuracyExact, if updates are atomic
Cost per requestOne round trip, under 1 ms
NotesThe usual choice
AccuracyApproximate; can overshoot briefly
Cost per requestNone on the request path
NotesGood for very high volume
The shared store is usually an in-memory key-value store like the one in the distributed caches chapter, with each client's counters on one shard: one round trip of under a millisecond buys an exact count. It brings its own trap, though, which the next page shows.
Atomic updates
The trap is a race condition: two gateways read the same count at the same moment, both decide, and both write. Each step is correct on its own; the problem is the gap between reading and writing.
Predict before you watch: the limit is 100 and user 42 has used 99. Two gateways read the count at the same moment. How many requests get through?
- Both: each sees room for one more, so 101 pass and the stored count says 100.
- Under heavy load many gateways do this at once, and the overshoot grows.
User 42 has used 99 of 100, and two requests arrive at the same moment
Read it as steps
Two gateway instances share one counter for user 42 in an in-memory store.
The fix is to make check-and-update one atomic step inside the store. For window counters, use the store's atomic increment, which returns the new value, and compare after incrementing. For a token bucket, run the whole read, refill, take and write as one script inside the store, so no other request can slip in between.
import time def allow(user_id, limit=100): window = int(time.time() // 60) # the current minute key = f'rl:{user_id}:{window}' count = store.incr(key) # atomic: returns the new value if count == 1: store.expire(key, 120) # tidy up after the window return count <= limit
Increment first, then compare, so two gateways can never both see 99. In production, set the expiry inside the same atomic script, so a crash between the two calls cannot leave a key that never expires.
Sizing the shared store
Every request now costs at least one call to the store for each rule it matches: an increment for a window counter, a short script for a token bucket. Suppose the gateways see 1 M requests a second at the tatkal peak, each matching one rule. Operations a second, not memory, decide the size of the store.
One in-memory node handles roughly 100,000 simple operations a second, so plain increments need about 10 nodes of capacity. A token-bucket script reads, refills and writes, so it costs several simple operations; at, say, 30,000 to 50,000 scripts a second per node, the same traffic needs 20 to 35 nodes. Keep each about two-thirds busy and give each a replica for failover. Measure your own script before you trust either number.
Memory is small by comparison. A token bucket needs a count and a timestamp; with the key and the store's own overhead that is about 100 to 200 bytes per client, so 10 M active clients need 1 to 2 GB.
Shard by client key. Hash the key, such as user 42 or a partner's API key, to pick the node, so each check is one operation on one node. Pre-count hot keys locally. One partner sending 50,000 requests a second would put them all on one shard. For keys like that, each of 20 gateways counts locally and sends one increment of about +250 every 100 ms: 200 store calls a second instead of 50,000, for a small overshoot.
Refill on the store's clock. A gateway whose clock runs 2 seconds fast would hand out 2 seconds of extra tokens, and one running slow can compute negative time. Read the time inside the atomic script from the store itself, so every gateway uses the same clock.
When the limiter itself fails
The store now sits on the path of every request, so it must not become the outage. Give each store call a tight timeout of a few milliseconds, and wrap it in a circuit breaker: after repeated timeouts, stop calling the store for a few seconds, so a dead store costs one timeout every few seconds instead of one on every request. Then decide in advance what happens while it is gone:
| Fail open | Fail closed | |
|---|---|---|
| When the store is down | Allow requests | Reject requests |
| Protects | Availability: users are not blocked | Downstream services and your bill |
| Risk | Abuse goes unchecked during the outage | A limiter outage becomes a full outage |
| Use for | Most API traffic | OTP sends, login attempts, costly third-party calls |
A good middle path is a local limiter in each gateway, set to the global limit divided by the number of instances, read at run time because autoscaling changes it. It is approximate in both directions. A passenger whose long-lived connection pins them to one gateway gets only that gateway's share, so some teams set the fallback a little above the fair share and accept some overshoot. Decide fail open or fail closed per rule, before the outage, not during it.
Across several regions, one global counter would add a cross-region round trip, 30 ms or more, to every request. The usual choice is to count per region, giving each region a share of the limit or syncing counts in the background, and accept a small overshoot.
Part 4 · Rules and Responses
Rules as data
With counting and failure settled, the last piece is the rules themselves. A real API has dozens: per plan, per endpoint, per partner, plus emergency blocks for a bot network found at 10:02. Hard-coding them means a deploy for every change, so keep rules as data: a table or config file that every gateway loads and refreshes every few seconds.
- match: POST /v1/otp key: phone_number limit: 3 per 10m algorithm: sliding_window_log if_store_down: fail_closed - match: /v1/* key: api_key algorithm: token_bucket limit: { free: 1 per 1s, pro: 20 per 1s } burst: { free: 10, pro: 100 } if_store_down: local_fallback - match: GET /v1/availability key: client_ip limit: 20 per 1s mode: shadow # log would-be 429s, block nothing yet
A request is checked against every rule it matches and must pass all of them.
Three habits make rules safe to change:
- Overrides are rows, not code. A partner running a sale gets 5× its limit for 24 hours, and the row expires on its own.
- Shadow mode first. Log which clients a new rule would block, check that list for real customers, then switch it to enforce.
- Treat changes like code. Validate every edit and roll it out to one gateway first: a typo that sets a limit to 0 is an outage.
Redis with a Lua script is the usual shared store: the script reads, refills and spends tokens atomically, and Redis Cluster spreads keys across nodes by hash. Envoy has a local rate-limit filter and can call a global rate-limit service for shared counts; pick it when Envoy already fronts your traffic, as in many service meshes. NGINX limit_req is a leaky bucket per key inside each instance, good for simple per-IP limits at the edge. Managed API gateways and CDNs have built-in throttling; use it first if it can express your rules.
What to send back
A limited client should learn three things: that it was limited, when to try again, and how much room it has. The status code for the first is 429 Too Many Requests, and the Retry-After header says how long to wait, usually in seconds.
HTTP/1.1 429 Too Many Requests Retry-After: 30 X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 30 Content-Type: application/json {"error": "rate_limited", "message": "Too many requests. Try again in 30 seconds."}
Header names for the limit details vary between APIs, and a standard set is still being worked out. Reset here means seconds until the window resets.
Use 429 when this client sent too much. When the whole service is overloaded no matter who is calling, 503 Service Unavailable with Retry-After is the honest answer, and dropping excess work that way is called load shedding. Successful responses can carry the same limit headers, so well-behaved clients slow down before they hit the wall. Limits also differ in how hard they bite:
| Hard limit | Soft limit | |
|---|---|---|
| Over the limit | Rejected with 429 | Allowed for a while, then flagged, slowed or billed |
| Example | 5 login attempts in 15 minutes | An API plan of 1 lakh calls a day with 10% grace and an email warning |
| Good for | Security and cost controls | Paying customers you do not want to cut off suddenly |
Choose hard where a breach costs money or safety, soft where cutting a customer off costs more. Either way, a client that retries a 429 at once, in a loop, makes things worse: it should respect Retry-After, or back off exponentially with jitter, as in the message queue chapter.
Part 5 · Interview kit
The 45-minute answer
A rate limiter is a building block in larger designs and a full question of its own. For "design a rate limiter", spend the 40 minutes of design time like this:
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | Requirements: who is limited, which limits, hard or soft, what a limited client sees | Key on user and API key; IP only for anonymous calls; under 1 ms added |
| 5–8 | Estimates: peak requests, active clients, store load | 1 M checks/s; 10 M clients ≈ 1–2 GB |
| 8–13 | API and data: allow(client, rule) returns allowed, remaining, retry-after; rules as data | Token bucket state: a count and a timestamp |
| 13–22 | High-level design: load balancer, gateways, a store sharded by client key, the rules config | Reject at the gateway, before any service works |
| 22–37 | Deep dive: the atomic script, hot keys, store failure, regions | One atomic call per check; fallback = limit ÷ gateways |
| 37–40 | Wrap-up: trade-offs, what to monitor, extensions | 429s per rule, store latency, shadow-mode hits |
Deep-dive the atomic counter if they ask how ten gateways enforce one limit, and failure modes if they ask about a slow store or a limit that guards money, such as OTP sends.
- Say
- "Token bucket with capacity 20 and refill 5 a second: short bursts pass, and the long-run rate stays at 5 a second."
- Say
- "If the store misses a 5 ms timeout, a breaker opens and gateways fall back to limit ÷ gateways; OTP and login fail closed."
- Avoid
- Limiting only by IP: a hostel shares one address, and IPv6 or a forged X-Forwarded-For header hands a script a fresh one.
Wrap-up
| Term | Meaning |
|---|---|
| Token bucket | Tokens refill at a set rate; each request spends one |
| Leaky bucket | A queue drained at a constant rate |
| Sliding window counter | Two window counts, weighted to estimate the last minute |
| Fail open / fail closed | Allow or reject requests while the limiter is down |
| Concurrency limit | A cap on requests in progress at once |
| Hot key | One client whose counting overloads a single store shard |
| Shadow mode | A rule that logs would-be rejections without blocking |
Next, the Blob Stores chapter looks at where photos, videos and backups live, and how files stay safe while disks keep failing.
Part 6 · Check yourself
Quick check
Q1A partner's export endpoint allows 10 requests a second, yet one partner ties up every export worker. Why is a rate limit the wrong tool, and what fixes it?
Think it through, then check.
- Each export runs about 30 seconds, so 10 a second means 300 running at once (in flight = rate × duration).
- A rate limit counts arrivals, not work still running. Add a per-partner concurrency limit, such as 5 in progress, with one entry per running request so crashed ones age out.
Q2At 10 am, hostel students get 429 on most availability searches while scraping scripts keep going. The limit is 20 a second per IP, read from the first X-Forwarded-For entry. What is wrong, and what fixes it?
Think it through, then check.
- The hostel shares one public IP, so hundreds of students share one budget; the scripts forge the first header entry or rotate IPv6 addresses to get fresh ones.
- Read the header from the right, skipping your own proxies; key IPv6 on its /64; key logged-in traffic on user id, with IP as a coarse backstop.
Q3The store goes down, and each of 20 gateways falls back to a local copy of the 100-per-minute rule. What limit does a user really get, and how should the fallback be set?
Think it through, then check.
- The load balancer spreads a user over all 20 gateways, each allowing 100: up to 2,000 a minute, 20 times the limit.
- Set each local limit to 100 ÷ 20 = 5 a minute, reading the gateway count at run time. A user pinned to one gateway then gets only 5, so some teams allow a little more.
Q4The store is unreachable for 5 minutes. For availability search and for OTP sends, do you fail open or fail closed, and what does each choice risk?
Think it through, then check.
- Search: fail open with the local fallback, risking 5 minutes of extra scraping, not a site outage.
- OTP: fail closed, since each message is billed and an open endpoint invites abuse; some real passengers wait for their code.
Q5From the distributed caches chapter: one partner sends 50,000 requests a second, and the store node holding its counter maxes out while the others idle. Why, and what fixes it?
Think it through, then check.
- It is a hot key: sharding by client key puts every check for that partner on one node.
- Each of 20 gateways counts locally and sends about +250 every 100 ms: about 200 store calls a second, for a small overshoot.