Skip to lesson
‹

Courses / System Design Interview / Chapter 16

Module 2 · Building Blocks · Chapter 16 of 39

Rate Limiters

17 sections · ~33 min✓ Fact-checked

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 limitKeyMain reason
3 OTP requests per 10 minutesPhone numberCost and abuse
2 booking attempts per minuteUser idFairness at the rush
1,000 calls per minutePartner API keyFairness between partner apps, pricing tiers
20 availability searches per secondIP address or IPv6 prefixScraping
2,000 bookings per second in totalThe whole booking serviceProtecting 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.

Limit the expensive thing

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.

WhereGood atWeak at
ClientDebouncing taps, backing off on 429Not a control: anyone can bypass it
Edge or CDNAbsorbing floods before they reach youKnows little beyond the IP and the path
API gatewayOne place for per-user and per-key limitsCannot see what a request costs inside
Inside the serviceLimits by operation, tenant or costThe work has already reached your servers
Before a dependencyStaying under a partner's quota, such as an SMS provider's rateMust 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.

Trust X-Forwarded-For only from your own proxies

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.

python
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.

Animated sceneA token bucket at the gateway0:23

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 bucketLeaky bucket
BurstsAllowed, up to the capacitySmoothed into a steady stream
Extra requestsRejected at onceQueued, then dropped when the queue is full
Latency addedNoneUp to the queue length ÷ the rate
Best forAPIs where short bursts are normalFeeding 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.

The boundary problem

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.

text
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.

Animated sceneA burst at the minute boundary0:15

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:

AlgorithmMemory per clientBurstsAccuracyGood for
Token bucket2 numbersUp to the capacityExact for its ruleMost APIs
Leaky bucketA queueSmoothed outExact output rateFeeding a fixed-rate dependency
Fixed window1 counterUp to 2× at a boundaryRough at the edgesSimple quotas, daily limits
Sliding window log1 timestamp per requestNone beyond the limitExactSmall limits where precision matters, such as logins
Sliding window counter2 countersBoundary burst mostly removedApproximate; assumes even trafficHigh-volume per-user limits
Token bucket

Memory per client2 numbers

BurstsUp to the capacity

AccuracyExact for its rule

Good forMost APIs

Leaky bucket

Memory per clientA queue

BurstsSmoothed out

AccuracyExact output rate

Good forFeeding a fixed-rate dependency

Fixed window

Memory per client1 counter

BurstsUp to 2× at a boundary

AccuracyRough at the edges

Good forSimple quotas, daily limits

Sliding window log

Memory per client1 timestamp per request

BurstsNone beyond the limit

AccuracyExact

Good forSmall limits where precision matters, such as logins

Sliding window counter

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.

Two limits are better than one

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

Core deep dive

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:

ApproachAccuracyCost per requestNotes
Sticky routing: hash each user to one gatewayExact while instances stay the sameNoneUneven load; counts reset when instances change
Shared store: counters in an in-memory storeExact, if updates are atomicOne round trip, under 1 msThe usual choice
Local counts, synced every few hundred msApproximate; can overshoot brieflyNone on the request pathGood for very high volume
Sticky routing: hash each user to one gateway

AccuracyExact while instances stay the same

Cost per requestNone

NotesUneven load; counts reset when instances change

Shared store: counters in an in-memory store

AccuracyExact, if updates are atomic

Cost per requestOne round trip, under 1 ms

NotesThe usual choice

Local counts, synced every few hundred ms

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.
Animated sceneTwo gateways race for the last slot0:17

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.

python
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 openFail closed
When the store is downAllow requestsReject requests
ProtectsAvailability: users are not blockedDownstream services and your bill
RiskAbuse goes unchecked during the outageA limiter outage becomes a full outage
Use forMost API trafficOTP 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.

yaml
- 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.
Names you will hear

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
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 limitSoft limit
Over the limitRejected with 429Allowed for a while, then flagged, slowed or billed
Example5 login attempts in 15 minutesAn API plan of 1 lakh calls a day with 10% grace and an email warning
Good forSecurity and cost controlsPaying 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:

MinutesWhat you say and drawKey number or decision
0–5Requirements: who is limited, which limits, hard or soft, what a limited client seesKey on user and API key; IP only for anonymous calls; under 1 ms added
5–8Estimates: peak requests, active clients, store load1 M checks/s; 10 M clients ≈ 1–2 GB
8–13API and data: allow(client, rule) returns allowed, remaining, retry-after; rules as dataToken bucket state: a count and a timestamp
13–22High-level design: load balancer, gateways, a store sharded by client key, the rules configReject at the gateway, before any service works
22–37Deep dive: the atomic script, hot keys, store failure, regionsOne atomic call per check; fallback = limit ÷ gateways
37–40Wrap-up: trade-offs, what to monitor, extensions429s 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

TermMeaning
Token bucketTokens refill at a set rate; each request spends one
Leaky bucketA queue drained at a constant rate
Sliding window counterTwo window counts, weighted to estimate the last minute
Fail open / fail closedAllow or reject requests while the limiter is down
Concurrency limitA cap on requests in progress at once
Hot keyOne client whose counting overloads a single store shard
Shadow modeA 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.

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.

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.

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.

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.

Next chapter · 17 of 39Blob Stores~31 min · 1 animated scene