Design a rate limiter
Design a rate limiter, built one requirement at a time: limiter middleware in the API gateway, a token bucket per key in a sharded Redis Cluster checked by one atomic Lua script, 429 with Retry-After, rules pushed from etcd by watch, fail-soft fallbacks, safe rule rollout with shadow mode, and per-region shares.
On this page
🚦 What is a rate limiter?
A rate limiter decides, for every incoming request, whether this caller still has allowance left. If they do, the request goes through; if not, it's turned away with 429 Too Many Requests before it costs your servers anything. It acts like the turnstile at a metro station: it doesn't care who you are, only whether you have a valid tap left, and it says no instantly instead of letting the platform overflow.
For this problem, we'll design a distributed, server-side rate limiter in front of a public API, enforcing rules like "10 requests a second per API key, bursts of 20" across a fleet of 50 gateways. We'll leave network-layer DDoS protection and monthly billing quotas for later. Interviewers like this one because the single-machine version fits in ten lines, and then every real constraint (50 gateways, one shared count, a 10 ms budget, a Redis node that can die) turns a choice into a trade-off you have to defend.
The 30-second answer
"I'd put the limiter in the API gateway, so every request passes one choke point and a 429 costs the services nothing. Each request maps to a key: API key for partners, user ID for logged-in users, IP only for anonymous routes. Each key has a token bucket in a Redis Cluster sharded by key, and the gateway runs one Lua script per request that refills from elapsed time and takes a token atomically, so two gateways can't spend the last token twice. An empty bucket gets 429 with Retry-After. Rules live in etcd and reach every gateway by watch. If Redis is slow or down, most routes fall back to a local bucket so the API stays up, while login and OTP fail closed."
What you’ll learn
- Build the design one requirement at a time, from the first shared count to rule pushes
- Pick an algorithm from what it remembers: fixed window, sliding log, sliding counter, token bucket
- Make check-and-take atomic with one Lua script, and size it: ~1M checks/s on ~20 Redis primaries
- Choose a failure mode per rule: fail soft to a local bucket, fail closed for login
- Roll out rules safely with watch, validation, shadow mode and kill switches, and split limits across regions
Interview radar
What they probe: where the check runs, which algorithm and why, how the count stays correct across many servers, how you stay inside the latency budget, and what happens when the counter store fails. Where candidates slip: local counters per app server, read-then-write without atomicity, a distributed lock per request, no answer for Redis going down, and a 429 that doesn't say when to retry.
011. Understand the problem
Functional requirements
Here's the scope I'd agree on in the first five minutes. The core requirements are:
- The system should identify each caller (API key, user ID or IP) and allow or reject each request against a shared count.
- The system should reject over-limit requests with 429 and tell the client when to retry.
- The system should support different limits per caller and per endpoint (login stricter than reads, paid plans looser than free).
- Operators should be able to change limits without redeploying the gateways.
Below the line
- Volumetric DDoS at the network layer (the CDN and edge handle that before traffic reaches us).
- Monthly billing quotas and metered invoicing.
- Throttling our own calls to third-party APIs (a client-side limiter).
- Load shedding based on overall system health.
At this point, ask your interviewer: "Is this protecting our API, or pacing us toward someone else's? Who calls it: anonymous browsers, logged-in users, partners with keys? How bad is 5% over the limit?" A limiter guarding a login form against password guessing and one metering a partner API have very different answers for accuracy and failure.
Before reading on: what are the non-functional requirements? Think about the latency every request pays, the request rate, what matters more when the limiter is sick, and how exact the count must be. Put a number on each.
Non-functional requirements
- Low latency. The check adds under 10 ms at p99, ideally 1 to 2 ms. It's on the path of every single request, so this number shapes the design.
- Scale. About 1 million requests a second at peak, from 100 million daily users.
- Availability over perfect accuracy. If the limiter is unhealthy, the API keeps serving. A few extra requests getting through is better than turning everyone away.
- Close is good enough. A user who briefly gets 105 requests instead of 100 is not an incident. Login is the exception: there, exact matters more than available.
Below the line
- Strict global accuracy across regions.
- Per-request audit logs kept for years.
022. The set up
Plan the approach
We'll build the design one functional requirement at a time, starting with where the check runs. Then we'll use the non-functional requirements to drive the deep dives: the algorithm, correctness across gateways, latency at a million checks a second, failure, rule changes, and multiple regions.
Core entities
Three entities do all the work:
- Rule: what it matches (plan, path), what it counts by (API key, user, IP) and the allowance (rate and burst).
- Client key: the identity we count against, such as
user:42,key:k_live_9, or an IPv6/64prefix. - Bucket: the live state for one client key under one rule: tokens left and the time of the last refill.
Three entities: a rule, a client key, and a bucket per pair
API
The limiter's own interface is one internal call. The interesting API is the response the client sees, because a well-behaved client can only slow down if we tell it how.
# internal: made by the gateway middleware on every request
allow(clientKey="key:k_live_9", rule="free-api", cost=1) -> { allowed, remaining, retryAfterS }
# allowed: pass through, and advertise the budget
HTTP/1.1 200 OK
RateLimit-Policy: "free-api";q=20;w=2
RateLimit: "free-api";r=14;t=1
# rejected: say no, and say when
HTTP/1.1 429 Too Many Requests
Retry-After: 2
RateLimit: "free-api";r=0;t=2
429 and the optional Retry-After come from RFC 6585; Retry-After can be seconds or a date (RFC 9110). The RateLimit and RateLimit-Policy fields are from an IETF draft, not an RFC yet; GitHub and many others send x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset instead (GitHub Docs). Either is fine, as long as the 429 carries Retry-After.
verified token, not a header the client sets. Behind a load balancer, read the client IP from X-Forwarded-For only as appended by your own proxies; everything to the left of that is whatever the client chose to send. And never let the client choose the rule (no ?plan=enterprise).
Back-of-envelope estimates
All inputs are assumptions to confirm with your interviewer; the arithmetic is ours and rounded.
| Quantity | Assumption | Roughly |
|---|---|---|
| Checks per second | one per request, 1M requests/s at peak | ~1M script calls/s |
| Active buckets | 100M users plus IPs and keys, ~2 rules each | ~200-300M at the busiest time |
| Memory per bucket | key + two numbers + overhead, budget ~100 B | 300M x 100 B = ~30 GB |
| Throughput per shard | budget ~100k script calls/s per Redis primary | ~10 primaries, ~20 with headroom |
| Latency budget | gateway to Redis in the same zone | about 1-2 ms of our 10 ms |
Notice what this tells us. Memory is small and buckets expire when idle, so this is a throughput and latency problem, not a storage problem. And one Redis node is out at 1M calls a second: we need a sharded cluster from day one.
033. High-level design
Let's start with the question that decides everything else: where does the check run?
Where can the limiter live? Three places, one good default
In the client is a courtesy, not a control: anyone with curl skips it. In each application server feels natural, but a load balancer spreads one user across many servers and each counts only what it sees; with 10 servers and a limit of 100, a user gets close to 1,000 through. In the API gateway is where I'd put it. Every request already passes through it, it knows who the caller is after authentication, and a 429 costs the services nothing. Envoy, NGINX, Kong and the cloud API gateways all ship a limiter here for that reason (Envoy, Amazon API Gateway).
Now let's satisfy the requirements one at a time. Each picture is the previous one plus a few new boxes, drawn in colour; what we already had turns gray.
1) Identify the caller and allow or reject each request
We need:
- API gateway fleet: about 50 stateless instances behind a load balancer. Authenticates and routes.
- Limiter middleware: runs inside each gateway, before routing. Builds the client key and asks for a decision.
- Redis: one shared place for the counts, so all 50 gateways agree.
- Services: whatever is behind the gateway.
Step 1: identify the caller and check one shared count
- A client sends a request; the load balancer hands it to any gateway.
- The limiter middleware builds the client key: API key for partners, user ID from the token for logged-in users, IP (a
/64prefix for IPv6) for anonymous traffic. - It asks Redis whether that key has budget left, and takes one unit if so.
- If allowed, the gateway forwards the request to the service.
Many corporate users share one IP behind a NAT, which is why IP is the key of last resort. Most real systems layer rules: a loose per-IP limit to slow floods, then a precise per-user or per-key limit for fairness. A request must pass every rule that matches.
2) Reject over-limit requests with 429, and say when to retry
No new boxes. We only add the "no" path, and it's the cheapest response in the system.
Step 2: say no fast, and say when to come back
- A request arrives.
- The limiter's check finds the bucket empty and gets back "retry in 2 s".
- The gateway answers
429 Too Many Requestsimmediately, withRetry-Afterand the remaining-quota headers.
Send the quota headers on successful responses too. A good client watches remaining fall and slows down before it ever sees a 429.
3) Support different limits per caller and per endpoint
The free plan gets 10 a second, enterprise gets 1,000, and login gets 6 a minute per IP no matter who you are. We add one box:
- Rule cache: the full rule table, held in memory in every gateway. Rules are read on every request, so they can't live behind a network call.
Step 3: different limits per caller and per endpoint
- The limiter matches the request (plan, path, method) against the rules in memory, and gets back the key type, rate, burst and failure mode for each matching rule.
- It checks one bucket per matching rule in Redis.
- id: free-api
match: { plan: free, path: "/v1/*" }
key: api_key
rate_per_second: 10
burst: 20
- id: login
match: { path: "/v1/login" }
key: ip
rate_per_second: 0.1 # 6 per minute
burst: 5
on_store_failure: closed
4) Let operators change limits without a redeploy
A partner launches and needs a temporary increase; an attack needs an emergency limit. Redeploying 50 gateways for either is too slow. We add:
- Rules API: where operators edit rules. Validates them and keeps every version.
- etcd: a small, strongly consistent config store with watches.
Step 4: change a limit without redeploying 50 gateways
- The operator edits a rule through the Rules API, which validates it (no
rate: 0on/*by accident). - The API writes the new version to etcd.
- Every gateway holds a watch on the rules prefix; etcd pushes the change, and each gateway swaps its in-memory table within about a second.
If etcd is unreachable, gateways keep serving with the last rules they had. That's the property you want from a control plane: when it's down, nothing on the data path notices.
The data plane (gateway, rule cache, Redis) handles every request and must be fast and always up. The control plane (Rules API, etcd) changes rarely and can be slow or briefly down. Push configuration from one to the other, keep it in memory on the data side, and never put a control-plane call on the request path. The same split shows up in load balancers, service meshes and feature flags.
044. Deep dives
The board above is correct on a whiteboard. Now we push it against the non-functional requirements. For a senior candidate I expect the algorithm, atomicity and the failure mode; for staff, I expect you to bring up sharding limits, rule rollout and regions on your own.
1) Which algorithm should we count with?
Before reading on: the limit is 100 requests a minute, counted in fixed one-minute windows. A client sends 100 requests at 12:00:59 and 100 more at 12:01:00. How many get through, and in how many seconds?
All 200, in about one second. That's the classic flaw, and a good way into the four algorithms you should know.
Naive approach: Fixed window counter
One counter per key per window: INCR rl:user:42:202410211201, set an expiry on the first increment, reject above the limit. Redis documents exactly this pattern (Redis INCR). One number per key, one command, easy to explain.
Challenges: up to twice the limit across a window boundary, as the try-it showed. And every client's window resets at the same instant, so traffic arrives in synchronised waves at the top of each minute.
Fixed windows let 200 through in about one second
Solid approach: Sliding window log, or sliding window counter
Sliding log: store a timestamp per request in a sorted set, drop entries older than the window, count the rest. It's exact. Sliding counter: keep this window's count and the previous window's, and weight the previous one by how much of it still overlaps. Fifteen seconds into a minute, 75% of the last minute still counts: 8 x 0.75 + 3 = 9. Two numbers per key, and it assumes the previous window's requests were evenly spread.
Challenges: the log's memory grows with traffic (a key allowed 10,000 an hour stores up to 10,000 timestamps). The counter is an estimate, and neither expresses "quiet, then a burst when the page loads", which is how real clients behave.
Staff approach: Token bucket for API limits, sliding counter at the edge
A bucket holds up to capacity tokens and refills at rate tokens a second. Each request takes one (or cost for expensive calls); empty means 429. It stores two numbers, allows a burst up to the capacity, caps the long-run average at the rate, and "10 a second, bursts of 20" is a rule a product manager can read. Refill is computed lazily from elapsed time, so idle keys cost nothing.
For huge, coarse edge limits (per IP across millions of sites), the sliding counter is the better fit: Cloudflare measured it on 400 million requests and found 0.003% wrongly allowed or limited (Cloudflare).
Challenges: a full bucket lets a burst of capacity through at once. If the backend can't take bursts, keep the capacity small or put a leaky bucket (a queue drained at a fixed rate, as NGINX's limit_req does) in front of that one service (NGINX).
Four ways to count: what each one remembers and where it slips
| Algorithm | State per key | Bursts | Accuracy | Good for |
|---|---|---|---|---|
| Fixed window | 1 counter | up to 2x at edges | rough | quick internal limits |
| Sliding log | every timestamp | none | exact | low-volume, high-stakes (login) |
| Sliding counter | 2 counters | smoothed | close estimate | edge limits at huge scale |
| Token bucket | tokens + timestamp | up to capacity | exact for its model | public API limits |
Stripe). Amazon API Gateway throttles with a token bucket described as rate and burst (AWS).
2) How does the count stay correct across 50 gateways?
Before reading on: two gateways handle requests from the same user at the same moment. Each reads
tokens = 1, decides "allowed", and writes back tokens = 0. What went wrong, and what's the cheapest fix?
Naive approach: Read, decide, write from the gateway
HGET the bucket, compute the refill and the decision in the gateway, HSET it back. The logic lives in normal application code, which is easy to test.
Challenges: two round trips with a gap between them. Both gateways read 1, both allow, both write 0: two requests on one token, a lost update that gets worse as one key gets hotter.
Read, decide, write: two gateways spend one token twice
Solid approach: Optimistic retry with WATCH and MULTI
WATCH the key, read it, compute, then MULTI ... EXEC the write. Redis aborts the transaction if the key changed since the watch, and the gateway retries.
Challenges: correct, but under contention for one hot key, gateways burn round trips on retries, which eats the latency budget exactly when traffic is highest. A distributed lock is worse still: acquire, read, write, release is four round trips on every request.
Staff approach: One Lua script per check, with Redis's clock
Move the whole decision into Redis. A script runs as a single unit; no other command touches the key halfway through (Redis scripting). One round trip, no race. Load it once and call it with EVALSHA, so you send a 40-byte hash instead of the script source.
-- KEYS[1] = bucket key, ARGV[1] = rate (tokens/s), ARGV[2] = capacity, ARGV[3] = cost
local rate, cap, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t = redis.call('TIME') -- Redis clock, not the gateway's
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or cap -- a new key starts full
local ts = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate) -- refill for the time that passed
local allowed, retry = 0, 0
if tokens >= cost then tokens = tokens - cost; allowed = 1
else retry = math.ceil((cost - tokens) / rate) end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(cap / rate) + 1) -- idle buckets disappear
return { allowed, math.floor(tokens), retry }
Notice three things. Time comes from Redis, so 50 gateways with slightly different clocks can't mint tokens. There's no background refill job. And the expiry is "time to fill up again": once a bucket would be full, deleting it changes nothing.
Challenges: a script blocks its shard while it runs, so keep it tiny (this one is microseconds). Test it like code, version it, and load it on every primary after a failover.
One Lua script, run atomically inside Redis, decides each request
3) How do we serve a million checks a second inside 10 ms?
Before reading on: one Redis primary does roughly 100k of these scripts a second. You need 1M. And one abusive IP sends 200k requests a second. What do you change?
Naive approach: One big Redis with replicas for reads
Scale one primary vertically, add replicas, and send reads to them.
Challenges: one primary tops out well under 1M scripts a second. Replica reads don't help, because every check writes; worse, replication is asynchronous, so a replica can be a few writes behind and hand out tokens that were already spent.
Solid approach: Redis Cluster, sharded by bucket key
Redis Cluster splits keys across 16,384 hash slots, each primary owning a range (Redis Cluster). Every bucket key lands on exactly one shard, so the script never needs cross-shard coordination: one key, one shard, one atomic script. About 10 primaries cover 1M a second; run 20 for headroom, each with a replica for failover only. To check several rules for one caller in one script, use a hash tag like rl:{user:42}:free-api so that caller's buckets share a slot.
Challenges: hot keys. One abusive IP at 200k requests a second lands entirely on one shard and can starve every other key on it.
Staff approach: Pooled connections, local "blocked until", and a local pre-limiter
Three cheap defences around the cluster:
- Pool connections from every gateway to every shard, so a check never pays for a TCP or TLS handshake (see TCP vs UDP).
- Remember "no" locally. When a key is rejected with
retryAfter = 30, the gateway caches "blocked until T" in memory and rejects locally until then. A flood of 200k a second becomes one Redis call per gateway per retry period. - A coarse local bucket in front: a per-gateway in-memory limit set generously absorbs bursts before they reach Redis. Envoy's docs recommend exactly this pairing of local and global limits (Envoy).
Challenges: "blocked until" is per gateway, so a blocked caller still costs one Redis call on each of the 50 gateways per period. That's 50 calls instead of 200k, which is the point.
One key, one shard, one script, and hot keys stopped at the gateway
4) What happens when Redis is slow or down?
Before reading on: a Redis primary fails, and its replica takes 5 to 10 seconds to be promoted. What should the gateways do with requests for keys on that shard during those seconds?
This is the question that separates a design from a sketch.
Naive approach: Fail closed: reject when you can't check
If the limiter can't reach Redis, return 429 or 503. It feels safe: nothing unmetered gets through.
Challenges: a limiter outage becomes a full API outage. You've put a single point of failure in front of every service, and all your customers see errors because of a component that exists to protect them.
Solid approach: Fail open: allow when you can't check
If Redis times out, let the request through. Stripe recommends this for errors in the limiter itself (Stripe).
Challenges: protection disappears exactly when something is already wrong, which is when retry storms and abusers show up. And for login, unlimited password guesses for 10 seconds is a real security hole.
Staff approach: Fail soft, and make the failure mode part of the rule
Call Redis with a tight timeout (2 to 3 ms) behind a circuit breaker. When a call fails or the breaker is open, fall back to an in-memory token bucket per key at limit ÷ number of gateways. Limits get fuzzier, since a user spread unevenly across gateways gets somewhat more or less, but abuse stays bounded and the API stays up. Page the on-call either way.
Routes where extra requests cause harm (login, OTP verification, password reset) carry on_store_failure: closed in their rule. Rejecting logins for a minute beats unlimited guesses.
Challenges: limit ÷ gateways assumes even spread. If the load balancer is sticky or one region has more gateways, adjust the divisor from the gateway count each instance sees in the fleet registry.
When Redis is slow or down: degrade to a local limit, do not take the API down
5) How do we roll out a rule change safely?
We already push rules by watch. The deep question is what stops a bad rule from limiting your biggest customer on launch day.
Before reading on: an operator ships a new rule, "100 requests a minute per IP on
/v1/*". What happens to a customer whose 2,000 servers sit behind one NAT IP?
Naive approach: Rules in a config file in the gateway image
Version-controlled, reviewed, deployed like code.
Challenges: every change is a 50-gateway deploy, which takes too long during an attack. And the first time you learn the rule is wrong is when it's already blocking real traffic.
Solid approach: Gateways poll a rules table every 30 seconds
Edits go to a database; gateways reload periodically.
Challenges: up to 30 seconds to apply, constant polling load, and still no way to see a rule's effect before it bites.
Staff approach: Watched store, validation, shadow mode and a kill switch
Rules live in etcd and are pushed by watch, as in step 4. Around that:
- Validate on write: no zero limits, no rule matching
/*with a key type that can't be built for every route. - Version every change, so rollback is one write.
- Shadow mode first. A new rule runs, but only logs "would have blocked"; you look at who it would have hit for a day, then switch it to enforce.
- Kill switch. Any rule, or the whole limiter, can be turned off in one write.
Challenges: shadow mode doubles the checks for that rule while it runs. Sample it (check 10% of traffic) if the rule is broad.
Rules flow one way: validated, versioned, shadowed, then enforced
dark launch each limiter, watching what it would have blocked before it blocks anything (Stripe).
6) What if we run in three regions?
Before reading on: a partner has 1,000 requests a minute, and calls us from servers in the US, Europe and India. Where does their bucket live?
Naive approach: One global Redis for everyone
All regions check one cluster, so the count is exact.
Challenges: a cross-region round trip is 70 to 200 ms, on every request. That blows the 10 ms budget by ten times, and a transatlantic network blip takes every region's limiter down.
Solid approach: Split the limit statically per region
Each region has its own cluster and enforces a fixed share: say a third each.
Challenges: traffic is rarely even. A partner who sends 80% from the US hits their US share at 27% of their total limit and feels limited unfairly.
Staff approach: Local enforcement, shares rebalanced from observed usage
Each region enforces its share locally, with no cross-region call on the hot path. A small rebalancer collects per-key usage from each region every 10 seconds and moves shares toward where the traffic actually is (500 / 300 / 200 instead of thirds). New keys start with an even split.
Challenges: a caller who suddenly shifts regions is under-served for up to one rebalance period, and a caller hitting all three regions at once can briefly exceed the total. Both fit "availability over perfect accuracy"; write it down so nobody is surprised.
Three regions, one limit: split it, then rebalance from what you see
055. The final design
Here's everything on one board: clients, edge, the gateway fleet with its limiter, the Redis Cluster, the services, and the control plane. The numbered steps follow one request, then one rule change.
Design a rate limiter: the whole system on one board
Let's trace it:
- A request reaches the WAF / CDN, which drops volumetric floods before they reach us.
- The load balancer hands it to any of the 50 gateways.
- The limiter builds the client key and matches the rules in the rule cache, in memory.
- It runs one
EVALSHAon the Redis primary that owns the key's slot. If Redis is slow, it falls back to the local buckets. - Allowed: the request goes to the service, with quota headers on the response.
- Rejected:
429withRetry-After, straight from the gateway. - Separately, the on-call engineer edits a rule through the Rules API, which validates and versions it.
- etcd's watch pushes it to every gateway's rule cache within about a second.
066. What interviewers look for by level
Mid-level
- Limiter in the gateway, with a reason.
- Identifying callers sensibly (key, user, IP).
- One algorithm explained correctly, usually token bucket or sliding window.
- A shared counter in Redis, and a 429 with
Retry-After.
Senior
- Atomic check-and-take with a Lua script, and why not a lock.
- A clear algorithm trade-off table.
- Sharding by key, with sizing, pooled connections and no replica reads.
- A deliberate failure mode, different for login.
Staff+
- Accuracy treated as a tunable cost: local pre-limits, blocked-until caches, token leases.
- Rule rollout: validation, shadow mode, kill switches.
- Multi-region shares rebalanced from usage.
- Observability for "why was I limited?", and how the limiter fits with load shedding and client backoff.
077. Follow-up questions
What if requests have very different costs?
Charge more than one token. A search that fans out to ten shards, a bulk export, or an LLM call measured in tokens takes cost tokens instead of one; the script above already takes a cost argument. Many GraphQL APIs score each query before running it and charge that score.
What if we need 10 million checks a second?
Ten times the shards works, because keys don't interact. The better answer cuts calls: each gateway leases a batch of tokens for a hot key (say 10 at a time), spends them locally and returns to Redis only when it runs out. Envoy's quota-based mode shares a global limit this way, from periodic load reports (Envoy). You trade a little accuracy for a big drop in Redis traffic.
What if a customer says they're being limited unfairly?
You need an answer in minutes. Log every rejection with key, rule, remaining tokens and gateway, and chart limited requests per key. Most cases turn out to be a shared office IP behind one NAT, or a retry loop in the customer's own code, and the chart shows which.
What if clients retry the moment they get a 429?
Then your limiter amplifies the problem. Return Retry-After on every 429, make your own SDKs honour it, and fall back to exponential backoff with jitter so thousands of clients don't retry in lockstep (Amazon Builders' Library). Only auto-retry idempotent requests, or POSTs with an idempotency key.
What if we need to limit concurrent requests, not requests per second?
That's a different counter: add when a request starts, remove when it ends, reject over the limit. Stripe runs one beside its rate limiter for expensive endpoints (Stripe). The trap is a crashed request that never removes itself, so store each in-flight request in a sorted set scored by start time and clear entries older than the request timeout.
088. How to say it in the interview
When I ask this question, I'm listening for three things: do you know where the check runs, can you make it atomic without making it slow, and do you have a plan for when it breaks.
In the interview
"I'd enforce limits in the API gateway, so there's one choke point and a 429 costs the services nothing. Each request maps to a key: API key for partners, user ID for logged-in users, IP only for anonymous routes, with IPv6 by /64. I'd use a token bucket, because it allows realistic bursts, caps the average, and is two numbers per key. Buckets live in a Redis Cluster sharded by key, and each check is one Lua script, called with EVALSHA, that refills from elapsed time on Redis's clock and takes a token atomically, so two gateways can't spend the last token twice. At a million checks a second that's about twenty primaries; memory is tens of gigabytes and idle buckets expire. Gateways pool connections, cache 'blocked until' for hot keys, and never read buckets from replicas. If Redis times out, most routes fall back to a local bucket at limit over gateways and we page someone; login and OTP fail closed. Rejections get 429 with Retry-After. Rules live in etcd, reach every gateway by watch, and every new rule runs in shadow mode first. Across regions, each region enforces a share that's rebalanced from observed usage."
On the whiteboard
- Placement: API gateway middleware, one choke point
- Key: API key > user ID > IP (/64 for IPv6); layer rules
- Algorithm: token bucket (rate + burst); sliding counter at the edge
- Atomic: one Lua script, EVALSHA, Redis TIME, lazy refill, EXPIRE when full
- Scale: Redis Cluster by key, ~20 primaries, pooled, no replica reads
- Hot keys: local "blocked until", local pre-limit
- Failure: tight timeout + breaker → local bucket; login fails closed
- Rules: etcd + watch, validate, version, shadow, kill switch
- Regions: local shares, rebalanced every 10 s
- Response: 429 + Retry-After; quota headers on every response
Concepts used in this design
- Rate limitingSoon
- HTTP and HTTPS
- Proxy vs reverse proxy
- Load balancing
Go deeper in Articles
Optional long reads behind this design, for when you want the full story.
Further reading (14)
- Scaling your API with rate limiters (Stripe): Four limiters and load shedders in production, token buckets in Redis, fail open, dark launches and kill switches.
- How we built rate limiting capable of scaling to millions of domains (Cloudflare): The sliding window counter: two numbers per key, and 0.003% of 400 million requests wrongly allowed or limited.
- INCR: pattern rate limiter (Redis docs): Fixed window counters with INCR and EXPIRE, and the race in the naive version.
- Scripting with Lua (Redis docs): Scripts run atomically; EVALSHA avoids resending the script.
- Scale with Redis Cluster (Redis docs): 16,384 hash slots and hash tags for keeping related keys on one shard.
- RFC 6585, section 4: 429 Too Many Requests: The status code, and that the response MAY include Retry-After.
- RFC 9110: HTTP Semantics (Retry-After): Retry-After as seconds or an HTTP date.
- RateLimit header fields for HTTP (IETF draft): RateLimit-Policy and RateLimit: advertising quota and what is left. Still a draft.
- Rate limits for the REST API (GitHub Docs): A real public API's limits and its x-ratelimit-* headers.
- Throttle requests to your REST APIs (Amazon API Gateway): Token bucket with rate and burst, applied on a best-effort basis.
- Global rate limiting (Envoy): A proxy calling a shared rate limit service per request, plus local limits in front of it.
- envoyproxy/ratelimit (GitHub): Reference rate limit service in Go, backed by Redis.
- ngx_http_limit_req_module (NGINX): A leaky-bucket limiter per key on a single server, with burst and nodelay.
- Timeouts, retries and backoff with jitter (Amazon Builders' Library): Why clients that get a 429 should back off with jitter instead of retrying in lockstep.