Design URL Shortener & Rate Limiter
Design a rate limiter in depth: token bucket, leaky bucket and window algorithms, distributed limits with Redis, where to enforce them and how to respond
SPACED REPETITION Β· 16 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 16Two warm-ups, and the one with teeth
Interviewers love two small prompts as openers: "design a URL shortener" and "design a rate limiter". Both are bounded in scope but unbounded in depth. Either one fits comfortably into the sketch phase, and a good interviewer can then fill the whole deep dive (minutes 20 to 35 of a 45-minute slot, in the course's Interview Framework & Strategy) asking what happens at 10Γ the traffic, when a node dies, or when two servers race each other. Cheap to start, hard to fake.
They also exercise opposite shapes of data, which is why they are taught together:
| URL shortener | Rate limiter | |
|---|---|---|
| Core operation | Look up a mapping that never changes | Read, update and decide on a counter, on every request |
| Read/write mix | Read-heavy: one create, many redirects | Every check is a write |
| Data lifetime | Years | Seconds to a day |
| Where it gets hard | ID generation, caching, redirect semantics | Atomicity across servers, algorithm choice, failure policy |
| Latency budget | The redirect is the product | A tax on every other request, so about a millisecond |
The URL shortener has its own lesson: URL Shortener (e.g. TinyURL) covers key generation, code length, 301 vs 302, caching and expiry. This lesson is the rate limiter, in depth. It assumes you know what a load balancer, a cache and a replica are (Core Building Blocks).
The naive plans meet real load
Here is the prompt. A public REST API runs on 30 stateless servers behind a round-robin load balancer and peaks at 40,000 requests per second. Each API key may make 600 requests per minute. Protect the backend from any single customer.
Plan A: count in each server's memory. A dictionary of counters per server. No network hop, nanoseconds per check.
Plan B: count in the main database. Run INSERT INTO limits (api_key, minute, n) VALUES ($1, $2, 1) ON CONFLICT (api_key, minute) DO UPDATE SET n = limits.n + 1 RETURNING n on every request. Shared, atomic, exact.
Predict first: one customer's script goes rogue and sends 3,000 requests per second. Which plan fails, and how would you find out?
Check your answer
Both fail, in different ways.
Plan A fails silently. Round robin spreads the rogue key over all 30 servers, and each one lets 600 a minute through, so the real limit is 30 Γ 600 = 18,000 per minute: thirty times what you promised to enforce. Nothing on the limiter side looks broken. One such key is only 300 requests a second, which the fleet absorbs; but with expensive endpoints, or a few dozen keys doing the same, that 30Γ looseness is exactly what reaches the backend.
Plan B fails loudly. The count is right, but every API call now carries a durable database write: at least 40,000 logged row updates a second, for numbers that are worthless a minute later. The rogue key's 3,000 requests a second all update the same row, so they queue on one row lock, each waiting for the previous commit. The limiter slows down exactly when the database is busiest, which is when you need it most.
The fix takes Plan A's speed and Plan B's shared truth: a shared in-memory store with atomic operations (usually Redis), plus an algorithm chosen by what the requirements say about bursts. The rest of the lesson takes that sentence apart.
| Section | Pressure in the requirements | Move | Signature use |
|---|---|---|---|
| Move 1 | "N per window"; a 2Γ burst at the boundary is tolerable | Fixed window counter | Coarse quotas, internal jobs |
| Move 2 | "Never more than N in any window" | Sliding log | Contractual or security limits with small N |
| Move 2 | Close to exact, but cheap | Sliding window counter | High-volume limits at the edge |
| Move 3 | Bursts are fine; the average must hold | Token bucket | Public API, per key |
| Move 4 | Downstream needs a steady rate; waiting beats rejecting, up to a bounded wait | Leaky bucket (a queue) | Calling a provider that limits you |
| Many servers, one limit | Many servers share one limit | Central atomic store | Redis plus one Lua script per check |
| When the limiter itself fails | The limiter's own store fails | A failure policy | Fail open, fail closed, local fallback |
Before any algorithm: pin down the contract
"Add a rate limiter" is not a requirement. Five questions turn it into one, and each answer changes the design:
| Ask | Why it changes the design |
|---|---|
| Who is limited: API key, user, account, IP, endpoint? | It becomes the key in your store, and a wrong key is a bypass |
| What exactly is the limit, and are bursts allowed? | It picks the algorithm (Moves 1β4) |
| What happens over the limit: reject, queue, or degrade? | Reject means a 429 and a counter or bucket; queue means a leaky bucket |
| How exact must it be? | Abuse protection tolerates a few percent of error; a quota you bill for does not |
| What happens if the limiter is down? | It decides fail open or fail closed, per endpoint |
Add the scale (requests per second, number of keys) and how much latency the check may add, and you have enough to estimate.
"100 requests per minute" is ambiguous
Predict first: the limit is 100 per minute. A client sends 100 requests at 12:00:59.9 and 100 more at 12:01:00.1. Are all 200 allowed?
Check your answer
It depends on what "per minute" means, and that is the point. If it means per calendar minute, yes: the first 100 belong to the 12:00 minute and the next 100 to 12:01. If it means in any 60-second span, no: all 200 fall inside a 0.2-second span. If it means an average of 100 a minute, with some burst allowed, it depends on the burst size. Three readings, three algorithms. Ask which one the interviewer means, or state yours.
Identity: who is "the client"?
Limit on the most trustworthy identity you have. For authenticated traffic that is the API key, user or account, known after authentication. Before authentication all you have is the network address, and it is weaker than it looks:
- Many users can share one IP. Carrier-grade NAT, offices and campuses put thousands of people behind one address, so a tight per-IP limit blocks innocent users.
- One user can have many IPs. An IPv6 host normally sits in a /64 subnet (2 to the power of 64 addresses), and customers are often given a /56 or /48. Key IPv6 limits on the /64 prefix, and add a coarser limit per larger prefix.
X-Forwarded-Foris client-controlled. Anyone can send the header. Each proxy appends the address it saw, so only the entries your own proxies added are trustworthy, and they sit at the right-hand end. MDN's X-Forwarded-For page describes the safe ways to pick one: count your trusted proxies from the right, or skip known proxy addresses from the right. A limiter keyed on the leftmost entry lets an attacker invent a new identity for every request.
Protection limits and quotas
Two very different jobs both get called "rate limiting":
- Protection (abuse, fairness, overload): "no key may send more than 600 a minute." If the count is off by a few percent for a few seconds, nobody loses money. Latency and availability matter more than exactness.
- Quota (what a customer pays for): "the free tier gets 10,000 calls a month, and we bill above that." A lost increment is lost revenue or a wrong invoice, so the count must be durable and exact. Record usage in a database or an append-only usage log and bill from that. A fast in-memory counter can serve as an early warning, never as the record.
In the interview, say which one you are designing: "This is abuse protection, so I'll trade exactness for latency and availability. If there's also a billing quota, I'll count that separately, in a durable store."
The numbers: what a limiter costs
Estimate before you pick a store. Assumptions for the running example:
- Peak 40,000 requests per second, and every request checks two limits: per API key and per client IP.
- The limits (Move 3 explains the choice): per API key, a token bucket with capacity 100 and a refill of 10 per second (600 a minute on average); per IP, capacity 200 and a refill of 20 per second.
- 200,000 API keys and 500,000 IPs are active within any two-minute stretch. That overestimates the live keys, because an idle bucket expires about 11 seconds after its last request (capacity Γ· refill, plus one), so the memory figure below is an upper bound.
- A counter costs about 100 bytes in Redis. Measured with
MEMORY USAGEon Redis 7.4: a string counter with a TTL used 72 bytes and a two-field hash used 104. - A sliding-log entry costs about 120 bytes in a large log (about 50β60 bytes each while a sorted set is small), measured the same way with 32-character request IDs as members.
- Budget 50,000 limiter script calls per second per Redis primary. That is half of what a token-bucket script like the one in this lesson reached under
redis-benchmarkon a laptop (about 100,000 calls a second). Measure your own hardware and keep the same headroom. - One round trip to Redis inside the data center: about 0.5 ms.
| Quantity | Arithmetic | Result |
|---|---|---|
| Limiter calls per second | 40,000 Γ 2 limits | 80,000 |
| Redis primaries | 80,000 Γ· 50,000 = 1.6, rounded up | 2, plus a replica each |
| Counter memory | (200,000 + 500,000) Γ 100 B | 70 MB |
| Sliding log, 60-second window | 40,000 Γ 2 Γ 60 entries Γ 120 B | 0.58 GB |
| Sliding log, 1-hour window | 40,000 Γ 2 Γ 3,600 entries Γ 120 B | 34.6 GB |
| Added latency | two calls, sent in parallel | about 0.5 ms |
Two lessons hide in that table.
Counters cost memory per key; logs cost memory per request. A counter's size doesn't depend on traffic. A sliding log holds every allowed request inside its window, so stretching the window from a minute to an hour makes it 60 times bigger, while the counters stay at 70 MB.
Every check is a write, so replicas don't add capacity. Each call changes a counter and must go to the primary that owns it. Replicas buy failover, not throughput; you scale by adding primaries (shards). Latency, for its part, is why each limit must cost one round trip: a design that needs three sequential commands at 0.5 ms each adds 1.5 ms to every API call.
In the interview: "At peak that's 80k limiter calls a second, so two Redis primaries with replicas. Counters are about 70 MB, so memory isn't the constraint; round trips and the write path are."
Your turn: traffic grows 10Γ to 400,000 requests per second, with the same number of keys. What changes first?
Check your answer
Calls grow to 800,000 a second, which is 16 primaries at the 50,000 budget. Counter memory barely moves, because keys drive it, not traffic: still about 70 MB. Sliding logs would grow tenfold, to 5.8 GB for one-minute windows and 346 GB for one-hour windows. So the pressure is shards and round trips, and at this scale it pays to stop floods before they reach Redis at all (the two-tier design later in this lesson).
Move 1: Fixed window counter
Pressure: a simple "N per minute", where an occasional burst at the edge of the minute is tolerable.
Cut time into windows (12:00:00 to 12:00:59, 12:01:00 to 12:01:59, and so on), keep one counter per key per window, and deny once it passes N. Put the window's start time in the key name, so each window gets a fresh counter and old ones simply expire.
import time
import redis
r = redis.Redis(decode_responses=True) # a local Redis 7+, e.g. docker run -p 6379:6379 redis:7
def allow_fixed_window(key: str, limit: int, window_s: int) -> bool:
window_start = int(time.time()) // window_s * window_s
k = f"rl:fw:{key}:{window_start}" # e.g. rl:fw:key42:1790265600
pipe = r.pipeline() # MULTI/EXEC: the two commands run as one unit
pipe.incr(k) # atomic; returns the new count
pipe.expire(k, window_s * 2, nx=True) # set a TTL only if the key has none yet
count, _ = pipe.execute()
return count <= limit
print(sum(allow_fixed_window("demo", 100, 60) for _ in range(105)))
# 100 (run it again within the same minute and you get 0: same window, same key)
Three details carry the correctness:
INCRis atomic. Redis executes one command at a time, so 30 servers incrementing the same key never lose an update. Reading the count withGETand writing it back withSETcan lose updates, because other servers' commands run in the gap; Move 3 shows the same race on a token bucket.- The TTL is set once, in the same transaction.
EXPIRE ... NX(Redis 7.0 and later) sets an expiry only if the key has none, andMULTI/EXECmeans a crash between the two commands can't leave a counter that never expires. The TTL is twice the window as slack for servers whose clocks disagree slightly. - Denied requests still increment. That is harmless here, because the key dies with its window.
The boundary burst
Predict first: the limit is 600 per minute. What is the most one key can get through in about one second?
Check your answer
1,200. Send 600 in the last half-second of one window and 600 in the first half-second of the next. Each window sees exactly 600, so nothing is denied, and the backend absorbs 1,200 requests in about a second. The comparison table in Move 2 replays this trick against the other algorithms that reject rather than queue.
When that burst matters, you need a sliding window (Move 2) or a token bucket with a small burst (Move 3). When it doesn't (a daily quota of 10,000, where 20,000 across midnight is harmless), fixed windows are the cheapest thing that works: one small counter per key and one round trip.
There is a second, quieter cost: synchronized resets. Every client that was denied comes back when the window resets, so blocked traffic returns as a spike at the start of each minute. Adding random jitter to the Retry-After you send spreads it out.
Two TTL traps
β οΈ No TTL at all. Every window leaves a key behind forever. With 200,000 keys active every minute, that is 200,000 Γ 1,440 minutes Γ 72 bytes, about 20.7 GB of dead counters a day. With Redis's defaults (maxmemory 0, policy noeviction) nothing stops it until the host runs out of memory; with a maxmemory set, Redis starts refusing writes, and the common volatile-* eviction policies never pick keys that have no TTL.
β οΈ A TTL that never fires. This version looks tidier, and it is badly broken:
# BROKEN: no window in the key, and the TTL is pushed back on every request
def allow_broken(user_id: str, limit: int = 100) -> bool:
pipe = r.pipeline()
pipe.incr(f"rate:{user_id}")
pipe.expire(f"rate:{user_id}", 60)
count, _ = pipe.execute()
return count <= limit
The counter resets only after 60 seconds of silence. A client sending a steady 30 requests a minute, well under the 100-per-minute limit, never pauses for 60 seconds, so its count only climbs. It is denied from request 101, at t = 200 s, and stays denied for as long as it keeps calling, because denied requests push the TTL back too. In a one-hour simulation it got 100 of its 1,800 requests through. Keep the window in the key, or set the TTL only when the key is created.
Move 2: Sliding windows
Pressure: "never more than N in any W seconds", or at least no 2Γ bursts at the boundary.
The exact one: a sliding log
Keep a timestamp for every allowed request, in a Redis sorted set scored by time. To check a request: drop the entries older than W seconds, count the rest, and admit the request if the count is below N. The three steps must happen as one, so they go in a Lua script. Redis runs a script to completion before it runs anything else (Redis scripting docs).
import uuid
import redis
r = redis.Redis(decode_responses=True)
SLIDING_LOG = r.register_script("""
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2]) -- seconds
local t = redis.call('TIME') -- Redis's clock, the same for every server
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
if redis.call('ZCARD', KEYS[1]) < limit then
redis.call('ZADD', KEYS[1], now, ARGV[3]) -- the member must be unique per request
redis.call('EXPIRE', KEYS[1], math.ceil(window))
return 1
end
return 0
""")
def allow_sliding_log(key: str, limit: int, window_s: int) -> bool:
request_id = uuid.uuid4().hex
return SLIDING_LOG(keys=[f"rl:log:{key}"], args=[limit, window_s, request_id]) == 1
print(sum(allow_sliding_log("demo", 10, 60) for _ in range(15))) # 10
This version avoids two traps that show up in real code:
- Members must be unique. A sorted set stores each member once. If the timestamp is the member, two requests in the same microsecond collapse into one entry and the log undercounts. A random request ID avoids that.
- Log only allowed requests. If you add every request before checking, denied requests fill the log too, and a client that keeps hammering never sees its window drain: it stays blocked indefinitely. That can be a deliberate penalty, but then say so.
The cost is memory per request: 0.58 GB for one-minute windows at our traffic and 34.6 GB for one-hour windows. The log is the right tool when N is small or exactness is contractual, and a poor one for "5,000 per hour" across millions of keys.
The cheap one: a sliding window counter
Keep the two fixed-window counters you already have, this window's and the previous one's, and weight the previous one by how much of it still overlaps the sliding window:
estimate = previous_count Γ (1 β elapsed / W) + current_count
Cloudflare described this approach in "Counting things, a lot of different things" (2017). Their example: the limit is 50 per minute, there were 42 requests in the previous minute, and 18 so far in a current minute that started 15 seconds ago.
def sliding_estimate(prev_count: int, curr_count: int, elapsed_s: float, window_s: float = 60) -> float:
overlap = (window_s - elapsed_s) / window_s # share of the previous window still inside
return prev_count * overlap + curr_count
print(sliding_estimate(42, 18, 15)) # 49.5: one more request pushes the key past 50
That is two counters per key, one script call (window start in the key name, as in Move 1, and in Redis Cluster the same hash tag on both so they live together, for example rl:{key42}:1790265540 and rl:{key42}:1790265600), and no 2Γ boundary burst. The price is an assumption: the formula treats the previous window's requests as evenly spread. Real traffic is close enough that Cloudflare reported only 0.003% of requests wrongly allowed or limited in their analysis of 400 million requests from 270,000 sources, with an average 6% gap between the real and the approximated rate. A client that crowds its requests into the end of a window can still beat it, as the next table shows.
Same traffic, five limiters
The limit is 100 per 60 seconds. Three traffic patterns, replayed against pure-Python versions of each algorithm with a simulated clock:
| Limiter | Burst: 150 at 59.9 s, 150 at 60.1 s | Crowding: 100 at 59.9 s, 150 at 119 s | Steady 2 per second for 5 minutes |
|---|---|---|---|
| Fixed window | 200, all within 0.2 s | 200 within 59.1 s | 500 |
| Sliding log | 100 | 100 | 500 |
| Sliding window counter | 100 | 198 within 59.1 s | 496 |
| Token bucket, capacity 100, refill 100/min | 100 | 198 within 59.1 s | 599 (120 in the first minute) |
| Token bucket, capacity 10, refill 100/min | 10 | 20 | 509 (at most 109 in any 60 s) |
Read it column by column. Only the sliding log never admits more than 100 in any 60-second span. The sliding window counter kills the boundary burst but can be gamed by crowding. A token bucket with a big capacity is generous on purpose, which is the next move.
Optional: run the comparison yourself
import math
from collections import deque
class FixedWindow:
def __init__(self, limit, window):
self.limit, self.window, self.current, self.count = limit, window, None, 0
def allow(self, now):
w = math.floor(now / self.window)
if w != self.current:
self.current, self.count = w, 0
if self.count < self.limit:
self.count += 1
return True
return False
class SlidingLog:
def __init__(self, limit, window):
self.limit, self.window, self.log = limit, window, deque()
def allow(self, now):
while self.log and self.log[0] <= now - self.window:
self.log.popleft()
if len(self.log) < self.limit:
self.log.append(now)
return True
return False
class SlidingCounter:
def __init__(self, limit, window):
self.limit, self.window = limit, window
self.current, self.curr_count, self.prev_count = None, 0, 0
def allow(self, now):
w = math.floor(now / self.window)
if w != self.current:
self.prev_count = self.curr_count if self.current == w - 1 else 0
self.current, self.curr_count = w, 0
elapsed = now - w * self.window
estimate = self.prev_count * (1 - elapsed / self.window) + self.curr_count
if estimate + 1 <= self.limit:
self.curr_count += 1
return True
return False
class TokenBucket:
def __init__(self, capacity, rate):
self.capacity, self.rate, self.tokens, self.last = capacity, rate, capacity, None
def allow(self, now):
if self.last is not None:
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
def worst_60s(times):
"""Most admitted requests inside any 60-second span."""
return max((sum(1 for u in times if t <= u < t + 60) for t in times), default=0)
limiters = {
"fixed window": lambda: FixedWindow(100, 60),
"sliding log": lambda: SlidingLog(100, 60),
"sliding counter": lambda: SlidingCounter(100, 60),
"token bucket b=100": lambda: TokenBucket(100, 100 / 60),
"token bucket b=10": lambda: TokenBucket(10, 100 / 60),
}
traffic = {
"burst": [59.9] * 150 + [60.1] * 150,
"crowding": [59.9] * 100 + [119.0] * 150,
"steady": [i / 2 for i in range(600)], # 2 per second for 5 minutes
}
for name, make in limiters.items():
cells = []
for pattern in traffic.values():
limiter = make()
admitted = [t for t in pattern if limiter.allow(t)]
cells.append(f"{len(admitted)} (worst 60 s: {worst_60s(admitted)})")
print(f"{name:20}", " | ".join(cells))
Move 3: Token bucket
Pressure: bursts are normal (an app opening fires a dozen calls at once), but the long-run average must hold.
A bucket holds up to b tokens: its capacity, or burst size. Tokens drip in at r per second. Each request takes a token; no token, no entry. A client that has been quiet has a full bucket and can send b requests at once. A client that sends steadily is held to r per second.
Why it works: the b + rΒ·T bound
Over any interval of T seconds, a key can spend at most the tokens it had at the start (never more than b) plus the tokens that arrived during it (rΒ·T). So:
admitted in any T seconds β€ b + rΒ·T
That one line answers most token-bucket interview questions.
Predict first: capacity 100, refill 10 per second. The product manager calls it "600 per minute". What is the most one key can send in any 60 seconds?
Check your answer
700: 100 saved-up tokens plus 60 Γ 10 refilled. Over one second it is 110, and over ten seconds 200. If the requirement is literally "never more than 600 in any minute", this bucket doesn't meet it. Use a sliding log, or shrink the bucket and slow the refill: capacity 60 with a refill of 9 per second gives at most 60 + 540 = 600.
No timers: refill lazily
You don't need a background job adding tokens. Store two numbers per key, tokens and the time they were last updated, and on each request compute how many tokens would have arrived since then. The refill, the check and the decrement must be one atomic step, so this is a Lua script again:
import redis
r = redis.Redis(decode_responses=True)
TOKEN_BUCKET = r.register_script("""
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2]) -- tokens per second
local t = redis.call('TIME') -- Redis's clock, shared by every server
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity -- a missing key is a full bucket
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)
local allowed, retry_ms = 0, 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
else
retry_ms = math.ceil((1 - tokens) / rate * 1000)
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) + 1)
return {allowed, math.floor(tokens), retry_ms}
""")
def allow_token_bucket(key: str, capacity: int, rate: float):
allowed, remaining, retry_ms = TOKEN_BUCKET(keys=[f"rl:tb:{key}"], args=[capacity, rate])
return allowed == 1, remaining, retry_ms
results = [allow_token_bucket("demo", capacity=20, rate=5) for _ in range(25)]
print(sum(ok for ok, _, _ in results)) # 20
print(results[-1]) # (False, 0, 19x): retry in just under 200 ms
The script makes four decisions you can defend:
- Redis's clock, not the app server's. Calling
TIMEinside the script means 30 servers with slightly different clocks all use one clock. (Scripts may callTIMEbecause Redis replicates a script's effects rather than re-running it: the default since Redis 5.0 and the only mode since 7.0.) math.max(0, now - ts)guards against time running backwards, for example after a failover to a replica whose clock is a little behind. Without it, a negative elapsed time would remove tokens.- The TTL is the time to refill completely (b Γ· r seconds, plus one). After that, a missing key and a full bucket mean the same thing, so Redis may delete idle keys for free.
- It returns what the client needs: the tokens left and, when denied, how long until the next token arrives. Those feed the response headers later in the lesson.
The race the script prevents
Run the same logic as separate commands from the app server (HGET, compute, HSET) and two servers can interleave:
| Step | Server A | Server B | Stored tokens |
|---|---|---|---|
| 1 | reads 1.0 | 1.0 | |
| 2 | reads 1.0 | 1.0 | |
| 3 | 1.0 β₯ 1, so allow; write 0.0 | 0.0 | |
| 4 | 1.0 β₯ 1, so allow; write 0.0 | 0.0 |
Both requests pass on one token, and the store shows 0, not β1: B's write simply overwrote A's. That is a lost update, and for a busy key it happens constantly. Redis running one command at a time doesn't help, because the gap is between commands. The fixes are a Lua script (above) or an optimistic WATCH/MULTI transaction that retries when the key changed underneath it.
Where token buckets show up
Stripe has described running its request rate limiter as a token bucket in Redis (Scaling your API with rate limiters, 2017). AWS documents that API Gateway throttles with a token bucket: your "rate" is the refill rate and your "burst" is the capacity. It also says throttles are applied "on a best-effort basis and should be thought of as targets" (API Gateway throttling), a useful reminder that production limiters are rarely exact.
Weighted requests come almost free: charge a request more than one token. If a search costs ten times the backend work of a lookup, take ten tokens for it, and the bucket now limits cost instead of calls. (Pass the cost as an argument and compare and subtract it instead of 1.)
Move 4: Leaky bucket, when you must smooth instead of reject
Pressure: the downstream needs a steady rate, and waiting is better than being rejected, up to a bounded wait. The classic case is you calling someone else's limit: an SMS provider that accepts 50 messages a second, a partner API, a fragile legacy database.
A leaky bucket is a queue with a fixed drain rate. Requests join a FIFO queue of capacity Q; a worker takes them off at exactly r per second; when the queue is full, new arrivals are rejected. The output is perfectly smooth, however bursty the input.
The difference in one line: a token bucket polices (admits bursts up to b and rejects the rest at once), while a leaky bucket shapes (delays requests so the output is steady).
The cost is waiting. The last request in a full queue waits Q Γ· r seconds, which gives you the sizing rule:
queue capacity = drain rate Γ longest acceptable wait
For an SMS provider at 50 per second, where a login code must go out within 30 seconds, Q = 1,500. A bigger queue helps nobody: the requests at the back would time out after you spent effort holding them.
nginx's limit_req module is a well-known implementation; its documentation says the limitation "is done using the 'leaky bucket' method". Excess requests are delayed until the burst queue fills, and then rejected; nodelay serves a burst immediately instead of pacing it. Its default rejection status is 503, so set limit_req_status 429 if you mean "this client is over its limit" (nginx limit_req).
When the producers are many services and the requests must survive a crash, the "queue" becomes a real message queue and the drain a consumer pool with a shared rate. Messaging & Queues covers the delivery guarantees and retries that come with it.
π§ Textbooks also describe a "leaky bucket as a meter" that rejects instead of queuing. It makes exactly the same decisions as a token bucket, mirrored: the bucket fills with requests instead of emptying of tokens. Implementations of it often store one timestamp per key, a scheme known as GCRA. If an interviewer says "leaky bucket", ask whether they mean the queue or the meter.
Your turn: a payments partner allows you 20 requests per second. Your checkout service produces a burst of 600 requests when a flash sale opens, and each checkout call times out after 10 seconds. Design the outbound limiter and say what happens to the burst.
Check your answer
Queue the calls and drain them at 20 per second. The whole burst takes 600 Γ· 20 = 30 seconds, but with a 10-second timeout only 20 Γ 10 = 200 calls can finish in time. So either cap the queue at 200 and reject the rest immediately with a clear error, or make checkout asynchronous: accept the order, show "processing", and let the queue drain over 30 seconds. You give up either some checkouts or instant confirmation. A 600-deep queue behind a 10-second synchronous timeout gives you the worst of both: 400 customers wait 10 seconds and then fail anyway.
Many servers, one limit
You've already seen Plan A fail: 30 servers counting locally let one key through 30 times over.
Predict first: you switch the load balancer from round robin to hashing on the API key, so each key always lands on the same server. Does per-server counting work now?
Check your answer
Mostly, and that is the trap. With sticky routing each key's count lives on one server, so the limit is exact, until that server dies or you add servers: the key moves and its count restarts at zero. A heavy key also concentrates all its traffic, and all its limiting work, on one server. It is a legitimate design for a small fleet, but you have coupled load balancing to rate limiting.
The main options, with what each costs:
| Option | Accuracy | Cost per request | Main risk |
|---|---|---|---|
| Local counters, limit Γ· N on each server | Exact only if traffic spreads evenly | None | Autoscaling changes N; skewed traffic is throttled early |
| Central store on every request (Redis plus a script) | Exact, except after a failover | One round trip | The store is now on every request's path |
| Central store with leases: each server takes L tokens at a time | Never over the limit, but up to N Γ (L β 1) tokens stranded on servers the client isn't using | One round trip per L requests | Useless for small limits |
| Two tiers: a coarse local bucket, then the central check | Central accuracy; floods stopped locally | Central calls only for traffic that passes locally | Two sets of settings to tune |
Leases need numbers, because they only pay off at scale. With 30 servers and leases of 20 tokens, up to 30 Γ 19 = 570 tokens can sit unused on servers the client isn't hitting. For a key allowed 600 a minute that is 95% of its limit: useless. For an enterprise key allowed 60,000 a minute it is under 1%, and it cuts that key's Redis calls twentyfold.
The two-tier design is what Envoy's documentation describes: a local token bucket "can absorb very large bursts in load that might otherwise overwhelm a global rate limit service", and the global service finishes the job (Envoy global rate limiting). It is also your defense against a hot key. A flood of 500,000 requests a second from one IP, or on one leaked API key used from thousands of IPs, would land on the single Redis shard that owns that counter. A per-server local bucket for the same key turns most of it away before it reaches Redis, which is why the local tier needs buckets per API key as well as per IP: a per-IP bucket alone does nothing against a key spread across thousands of addresses.
The running example, assembled
Put together, one request in the running example takes this path:
client βββΊ load balancer βββΊ API server (1 of 30)
β
ββ 1. local token buckets per IP and per key, in memory (turn floods away, no network)
β
ββ 2. two Lua calls, sent in parallel, 5 ms timeout:
β per-key bucket βββΊ Redis primary A βββΊ replica
β per-IP bucket βββΊ Redis primary B βββΊ replica
β
ββ 3a. both allowed βββΊ handler βββΊ database
ββ 3b. either denied βββΊ 429 + Retry-After (no work done)
At peak, 40,000 requests a second enter and 80,000 script calls a second reach the two primaries (A and B are whichever primaries own the two keys' slots; sometimes they are the same one), for one round trip of about 0.5 ms per request. The local buckets are set generously, well above each server's fair share of the global limits, so only floods ever trip them. If a Redis call times out, the server applies the endpoint's failure policy (the next section) instead of waiting.
Sharding the counters
Counters shard by key. Redis Cluster assigns every key to one of 16,384 hash slots (CRC16 of the key, modulo 16,384), and each primary owns a set of slots. That is fixed-slot sharding, and the Redis documentation points out that it is not consistent hashing (Scale with Redis Cluster). Two practical consequences:
- A script may only touch keys in one slot. That is why the sliding window counter's two keys share a hash tag: only the part inside the braces is hashed.
- A per-key limit and a per-IP limit live in different slots, so they are two calls. Send them in parallel so the request pays one round trip, not two.
How consistent is it, really?
Redis replicates asynchronously. If a primary acknowledges three increments and crashes before its replica receives them, the promoted replica is three requests behind, and the client gets three extra requests in that window. The Redis Cluster documentation says plainly that it "does not guarantee strong consistency", for this reason. Executing one command at a time makes each operation atomic; it doesn't make a replicated deployment strongly consistent.
For protection limits this is the right trade. A few extra requests after a rare failover cost nothing. A consensus-backed (CP) store would put a quorum round trip on every API request and refuse to answer wherever it can't reach a quorum, at which point you would have to fail open or closed anyway. Most production limiters go further on purpose: Stripe's limiters fail open (next section), and AWS calls its throttles targets. Keep exactness for the quotas you bill, in a durable store. Key Concepts & Terminology covers CAP and consistency models precisely.
In the interview: "Counters live in Redis Cluster, one Lua call per limit, with the per-key and per-IP checks sent in parallel. Replication is asynchronous, so a failover can let a few extra requests through, and for abuse protection I accept that. The billing quota is counted separately, in the database." The likely follow-up is "what if Redis goes down?", which is the next section.
Your turn: go multi-region. The API now runs in three regions, 80 to 180 ms apart. Limits are global per key, and the check may add at most 5 ms. Keys usually stay in one region but may move. What do you do?
Check your answer
A single global Redis is out: one cross-region round trip alone costs 80 to 180 ms. Enforcing the full limit independently in each region is out too, since a client spreading its traffic across regions gets 3Γ. Two designs work:
- Count locally, sync asynchronously. Each region keeps its own counters and publishes per-key counts to the others every second or so, and each check adds the latest remote counts to the local one. Over-admission is bounded by what the other regions admit during one sync delay.
- Split the budget. Give each region a share of each key's limit, weighted by where its traffic actually goes, and rebalance periodically. Simpler, but a key that suddenly moves region is throttled early until the next rebalance.
Either way you trade exactness for latency. Say so, and give the bound.
When the limiter itself fails
The limiter sits in front of every request, so its failure modes become your API's failure modes. Decide each one in advance.
| Event | What happens | Move | What you give up |
|---|---|---|---|
| Redis unreachable | Every check errors | Fail open, fail closed, or fall back to local limits (limit Γ· N per server) | Open: a spell of unenforced traffic. Closed: an outage |
| Redis slow | Every API call slows down | A short timeout (a few ms) and a circuit breaker; treat a timeout as "unreachable" | Some checks skipped |
| Failover to a replica | Recent increments lost | Accept it; the overshoot is small | Exactness for a few seconds |
| Redis crashes and restarts | Counters written since the last snapshot are lost (the default config snapshots periodically; the append-only file is off unless enabled, and loses about a second when on) | Accept it for protection limits | Some keys get a fresh window |
| Clients retry 429s immediately | Rejections multiply the load (a retry storm) | Retry-After, client backoff with jitter, a cheap rejection path |
Nothing you want to keep |
| Fixed windows reset together | Every blocked client returns at once | Jittered Retry-After, or a token bucket |
Little |
| An attacker rotates IPv6 addresses | Millions of new keys, each under its limit | Key on the /64 prefix; add limits per larger prefix | Coarser identity |
| One key or one IP floods | One Redis shard saturates | Local buckets per key and per IP reject first | Local tuning |
Fail open or fail closed?
It is a per-endpoint business decision, and a strong answer makes it out loud:
- Fail open when the limiter protects capacity and a few minutes without it is survivable. Stripe wrote that it catches exceptions at all levels so that "any coding or operational errors would fail open and the API would still stay functional". Envoy's rate limit filter behaves the same way by default: its
failure_mode_denysetting defaults to false, so traffic flows when the rate limit service doesn't answer. - Fail closed, or fall back to a strict local limit, when every extra request costs money or security: sending SMS, password attempts, expensive AI calls. Blocking for three minutes is cheaper than giving an attacker three free minutes.
Whichever you choose, bound the wait. A limiter call that times out after 5 ms and then applies the policy keeps the API fast. One that waits for a one-second connection timeout on every request turns a Redis hiccup into a site-wide slowdown.
β οΈ Roll out in shadow mode. Stripe's advice is to "dark launch" each limiter: log what it would block, check that blocking it is the right decision, and only then enforce. A mis-sized limit is an outage you caused yourself.
Where to enforce
Limits can live at several layers, and real systems use more than one:
| Layer | Knows | Good for | Weakness |
|---|---|---|---|
| Client or SDK | Its own calls | Politeness, fewer 429s | Not enforcement: attackers skip it |
| Edge: CDN, WAF, load balancer | IP, path, headers | Volumetric floods, stopped before your servers pay | No authenticated identity |
| API gateway or sidecar proxy | API key, route | Per-key and per-route limits in one place | A coarse view of cost |
| Inside the service | User, tenant, request cost | Business rules, cost-weighted limits | Every service must do it |
| Outbound, before a dependency | Your own call rate | Respecting a partner's limit (Move 4) | Adds queueing |
Two ordering rules:
- Limit before expensive work, after identification. A per-IP limit goes before authentication, because the login endpoint is itself a target. The per-key limit goes right after authentication and before any database work or side effect. A rejected request then did nothing, so the client can safely retry it.
- Rate is not concurrency. A burst of 100 two-second report queries occupies 100 workers at once, even if it fits within a rate limit. Stripe describes a separate concurrent requests limiter (at most 20 requests in flight per user, in their example) alongside its rate limiter, plus load shedders that protect the whole fleet. Rate limits keep clients fair to each other; load shedding (usually a 503) protects the service when everyone is busy.
Telling the client: 429 and friends
A limiter that rejects silently teaches clients to retry harder. Tell them what happened and when to come back:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 12
RateLimit-Policy: "per-key";q=600;w=60
RateLimit: "per-key";r=0;t=12
{"error": "rate_limited", "retry_after_seconds": 12}
- 429 Too Many Requests is defined in RFC 6585: the user "has sent too many requests in a given amount of time". The RFC deliberately doesn't say how you identify the user or count requests. Keep 503 for "the service is overloaded" (load shedding), not for one client's limit.
Retry-After(defined in RFC 9110) takes either a number of seconds or an HTTP date. Compute it from your algorithm: the time until the window resets, or until the next token (the token-bucket script returns it).RateLimitandRateLimit-Policycome from an IETF draft (draft-ietf-httpapi-ratelimit-headers, version 11 in May 2026) that is not yet an RFC. In the policy,qis the quota andwthe window in seconds; in the current state,ris the quota remaining andtthe seconds in which it applies. The HTTP basics behind all this are in Networking Basics.- Many APIs still send the older
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Resetheaders, and they disagree on details. GitHub'sx-ratelimit-resetis a UTC epoch timestamp, while Envoy's optional headers (based on an earlier draft) give the reset as seconds from now. GitHub also answers an exceeded limit with either 403 or 429 (GitHub REST rate limits). Whatever you send, document it.
The client's half of the deal: honor Retry-After, and when there isn't one, back off exponentially with random jitter so a thousand throttled clients don't all retry in the same millisecond.
In the interview: "Over the limit I return a 429 with Retry-After computed from the bucket, and remaining-quota headers on every response, so well-behaved clients slow down before they are rejected." A likely follow-up is "what stops clients from ignoring Retry-After?" Nothing does, which is why rejection must be cheap: the check runs before any real work, and persistent offenders can be blocked at the edge.
Final round: no label on the problem
Real prompts don't name the algorithm. For each one: pin down the contract, estimate where it matters, pick the move, and name what you give up.
Challenge 1: webhook delivery
Your platform sends webhooks to customers' servers. A customer's endpoint handles at most 20 requests per second, and a nightly batch produces 10,000 events for one customer within a minute. Events must not be lost.
Check your answer
This is you respecting their limit, and nothing may be dropped, so it is a leaky bucket per destination whose queue is durable and big enough for the whole backlog: one queue per customer endpoint, drained at 20 per second. 10,000 events take 10,000 Γ· 20 = 500 seconds, a little over 8 minutes; say that out loud and confirm it is acceptable. Retry with backoff when the customer answers 429 or 503, and move an event to a dead-letter queue after repeated failures. One customer's backlog must not delay the others, so the queues are per destination, not one global queue. You give up freshness during bursts.
Challenge 2: a free tier with overage billing
A freemium API: 10,000 calls a month free, billed per call above that, and never more than 10 requests a second per key.
Check your answer
Two limits of different kinds, so two stores. The 10 per second is protection, and "never more than 10" is a hard ceiling: a sliding log of 10 per second per key in Redis (N is tiny, so it's cheap), or a token bucket whose capacity plus one second of refill is at most 10 (capacity 2, refill 8 per second). A bucket with capacity 20 and refill 10 would admit 30 in one second. Either way it is approximate across failovers and fails open. The monthly quota is money: every call becomes a usage record in a durable store, such as a database table or a log that feeds one, aggregated per key. A Redis counter can serve as a fast "you are near your quota" hint, reconciled from the durable count. Bill overage from the durable count, never from Redis, because a failover or a restart must not change an invoice.
Challenge 3: login attempts
Stop password guessing on a login endpoint. Attackers run credential stuffing from thousands of IPs, and sometimes hammer one account from one IP. Legitimate users must not be locked out by an attacker.
Check your answer
One identity isn't enough, so use several keys: failures per account (say 5 per 15 minutes), all attempts per IP (and per IPv6 /64), and a global signal for failures spread thinly across many accounts. The per-account counter counts failures only, so a user who logs in successfully is never slowed down. A hard lockout per account lets an attacker lock out a victim with five bad passwords, so escalate instead: growing delays, then a CAPTCHA or a second factor. Small numbers and a security purpose make an exact sliding log cheap and justified. If Redis is down, don't fail fully open on login; fall back to strict per-server limits.
Cheat sheet: pressure β move β cost
| Pressure | Move | What you pay |
|---|---|---|
| "N per window", edge bursts acceptable | Fixed window counter: window in the key, TTL set once | 2Γ at boundaries; synchronized resets |
| "Never more than N in any W" | Sliding log: sorted set, Lua, unique members, log only allowed requests | Memory per request |
| Near-exact, at scale | Sliding window counter | An approximation that crowding can beat |
| Bursts fine, average must hold | Token bucket (b, r), lazy refill in Lua | Up to b + rΒ·T in any T seconds |
| Downstream needs a steady rate | Leaky bucket queue, capacity = r Γ longest wait | Waiting; queue storage |
| Many servers, one limit | Central Redis, one atomic script per limit | A round trip; a new dependency |
| Limits far above servers Γ lease size | Leases, or a local tier in front | Stranded tokens; more tuning |
| The limiter's store fails | Fail open or closed per endpoint; short timeout; local fallback | Brief over- or under-admission |
| Money is involved | Durable, exact counting; Redis only as a hint | Latency and complexity |
| Clients need to behave | 429, Retry-After, remaining-quota headers |
Nothing: always do it |
Before moving on, design the running example aloud as the deep dive of a 45-minute interview: the 15 minutes from minute 20 to minute 35 on the Interview Framework & Strategy clock. Cover the contract questions you would ask; the numbers (80,000 calls a second, 2 primaries, 70 MB); the algorithm and why; the four decisions inside the Lua script; what happens when Redis fails over and when it disappears; and the response a throttled client sees. Then have someone interrupt with "what if one customer sends 100 times more than everyone else?" If you can answer that without notes, you have the rate limiter.
Next: URL Shortener (e.g. TinyURL), the other warm-up; Messaging & Queues for the queues behind leaky buckets and webhooks; and Mock Interviews & Communication to practise saying all of this against a clock.