Ask for an OTP three times in a minute and the app says "try again in 30 seconds". That
message comes from a rate limiter: a small piece of code that counts how many requests
someone has made recently and turns the extra ones away. It protects your servers, and it
keeps one noisy caller from hurting everyone else.
The noisy caller is usually not an attacker. It is a retry loop. Something times out, a
client retries, the retry times out too because the first request is still holding a
connection, and within a minute one integration is sending forty times its normal traffic.
The customer does not know. Your pager knows. Watch one request go through the design this
page builds.
Arrive. A request from one user reaches the load balancer.
times out, retries, times out, retriesone integrationyour APIwithin a minute: 40 times its normal traffica retry loop, not an attacker
Words this page uses a lot:
A 429 is the HTTP status code for "Too Many Requests". It is how a server says "you,
specifically, slow down".
Retry-After is a response header that tells the client how many seconds to wait
before trying again.
The API gateway is the front door every request walks through before it reaches your
services. The proxy vs reverse proxy vs API gateway
article explains where it sits and what else it does.
Rate limiting has close cousins, throttling (slow down instead of refusing) and backpressure
(let the slowdown travel back to the sender).
Rate limiting vs throttling vs backpressure
puts the three side by side. This page is about the first one.
A rate limiter looks like one number in a config file, so people start drawing straight
away. Ask these first. Two of the six answers decide the whole design.
You ask
They say
What it settles
What are we limiting: users, API keys, IPs, endpoints?
Per user and per API key, with a lower limit on expensive routes.
The name of each counter, rl:{user}:{route}:{window}. One user calling two routes needs two counters, which is why the number of counters gets large.
How many API hosts share one limit?
Twenty, behind a load balancer.
The whole problem. If each host counts on its own, a limit of 100 quietly becomes 2,000. The count has to live outside the hosts, and all twenty have to agree on it.
How exact does the limit have to be?
Close enough. Nobody is billed for it.
Your escape hatch later. Approximate counting is what lets you skip the network for most requests when the limiter itself gets slow. If the answer were "exact", as it is for login attempts, the design gets slower and more careful.
What does a rejected client see?
A 429, and enough information to back off sensibly.
Retry-After plus the X-RateLimit headers, with a little randomness added. A limiter that rejects without explaining causes the very retry storm it exists to prevent.
Can limits change without a deploy?
Yes, during an incident, in seconds.
Rules live in config the gateway watches and reloads. That also means a bad config change can take you down, so the config path needs validation.
If the counter store dies, does the API stay up?
The API stays up. Always.
Fail open, with a local bucket as the floor. Settle this in minute three, not as a surprise question in minute forty.
Three inputs. The point of the exercise is to find out whether the hard part is memory or
speed. (New to this kind of estimate? The back of the envelope
chapter walks through the method.)
Counter budget
50 thousand
5 million
3
Redis operations per second1 per request = 50,000
Live counter keys5M x 3 = 15,000,000
Memory at ~90 bytes a key1.4 GB
Budget per check5ms p99 = one round trip, no more
50,000 ops/sec
on the counter store, and every one of them sits in the path of a real request
1.4GB fits in the memory of one machine, so space is not the problem. Speed is. Every request waits for its check, which pushes the design toward one round trip per request and nothing clever.
The response headers matter more than the list of endpoints. A rate limiter that rejects
without telling the client anything produces exactly the retry storm it exists to prevent.
GETany rate limited route
returns 200 with X-RateLimit-Limit: 100, X-RateLimit-Remaining: 43, X-RateLimit-Reset: 1756112400
Why: Send the headers on success too. A polite client slows itself down as Remaining falls, which is far cheaper for both sides than hitting the wall at full speed.
GETthe same route, over limit
returns 429 with Retry-After: 17
Why: Retry-After in seconds, with a little random jitter per client. Without jitter, every rejected client comes back at the same instant and you get a stampede on a schedule you built yourself.
The counters live in Redis, an in-memory key value store that answers in well under a
millisecond. Each counter is one key with a number in it, and every key has a TTL (time
to live): Redis deletes the key by itself when the time runs out. The
caching patterns chapter covers TTLs in more depth.
Redis keyspaceone key per user per rule per window
key
string
PK
rl:{userId}:{route}:{windowStart}
value
integer
Requests counted in this window. Only ever incremented, never read and then written.
ttl
seconds
IDX
Window length plus a small margin. Expiry is the whole cleanup story.
Sample row
rl:4471:POST /search:29268540 | 17 | expires in 43s
There is no cleanup job and no delete path. Every key carries a TTL, so an idle user costs nothing the moment their window ends. Forgetting the TTL is how a limiter turns into a memory leak.
Figure 1 is the same path as the picture at the top, with the metrics box added.
12check first3INCR, near limit4rulesif allowed5forward6ClientLoad balancerAPI gatewayx20, statelessLocal bucketapproximateLimit configwatched, hot reloadRedis clustercounters, shardedAuth servicetoken checkMetrics429 rate, fallbacksYour service
Figure 1. The check happens in the gateway, before authentication does any expensive work and before anything touches a database. The local bucket and the config are what keep the limiter from becoming a single point of failure.
Walking Figure 1:
The request lands on any gateway host. A load balancer spreads requests across the
hosts (the load balancing chapter shows how it picks one).
The hosts are interchangeable, which is exactly why the count cannot live inside one of
them.
The gateway looks up the rule for this user and route. Rules come from watched config,
so tightening a limit during an incident is a config push and not a deploy.
It checks its own in-memory bucket first. For the great majority of users, who are
nowhere near their ceiling, the answer is already known here and the request never
leaves the machine.
Only users close to a limit cost a network call. That is one atomic INCR against
Redis, and the value it returns is the decision. (Atomic and INCR get a full section
in the deep dive.)
Allowed requests go on to authentication, then to the service. Rejected ones turn round
here with a 429 and a jittered Retry-After, having cost you a hash lookup.
Every decision is counted. You want a dashboard of 429s per route, and an alert when a
gateway falls back to local-only counting. Otherwise nobody notices the day your limits
quietly stopped being shared.
Two placement choices are worth defending out loud. The limiter runs in the gateway, not
inside each service, so one counter covers every route and a rejected request never costs
a service call. And it runs after cheap identification (reading the API key) but before
expensive authentication (proving the key is real, often with a database lookup).
Checking a token on a request you are about to reject is exactly the work you were trying
to avoid. Authentication vs authorization
explains the two checks that come after.
Your answer
Twenty gateway hosts share one limit of 100 requests a minute. What goes wrong if each host reads the counter, compares it to the limit, and writes the new value back?
The counting rule is called the algorithm, and this is the part of the question with a
right answer. The three below are the ones interviewers expect you to compare.
How do we decide whether this request is over the limit?
1
First try
Fixed window: one counter per clock minute
Keep a counter for the current minute, say 10:04. Add one per request. Over 10, reject. At 10:05 a fresh counter starts at zero. One integer per user, and Redis does it with a single command.
Where it breaks: At the edge of the minute. Limit 10 a minute: a client sends 10 requests at 10:04:59 and 10 more at 10:05:01. Each burst fits its own minute, so all 20 get through in two seconds. A cron job that happens to fire on the boundary gets close to double its limit, forever, and nothing reports an error.
2
Better
Sliding window log: remember every request time
Store the timestamp of each request. On a new request, drop timestamps older than one minute and count what is left. The window moves with the clock, so there is no boundary to game.
Where it breaks: Memory. A user with a limit of 5,000 a minute means up to 5,000 stored timestamps, trimmed on every request, for every active user.
3
What you ship
Token bucket: a refilling allowance
Each user has a bucket that holds up to 10 tokens and gains one token every 6 seconds (10 a minute). A request takes one token. No token, rejected. The bucket starts full, so a short burst is fine, and after that requests are paced by the refill. Two numbers per user: tokens left and the time of the last refill.
A worked example of the token bucket, with that limit of 10 a minute. At 0:00 a client fires
10 requests: the full bucket pays for all of them and is now empty. A request at 0:03 is
rejected, because no token has come back yet. At 0:06 one token has refilled, so the next
request is allowed. The client gets its burst, then a steady one request every six seconds.
a burst of 10 at 0:00spends all 10 tokens1 token back every 6 s0:03: empty, rejected0:06: one token, alloweda burst, then one request every six seconds
Now see the three on the same traffic. It is deliberately nasty: quiet, then ten requests
packed against a window boundary, then a steady stream.
0ms2000ms4000ms6000msallowedrejected
21 of 24 allowed (limit 8 per 2000ms)
Count per fixed bucket, reset at the boundary. Cheap, and wrong exactly at the edges: the four requests packed just before 2000ms and the eight packed just after it all get through, because the counter resets between them. That is twelve requests inside half a second against a limit of eight per two seconds. A client that learns to time the boundary gets close to double its limit, forever, with no error and nothing in a dashboard to show it.
Switch between the three and watch the requests either side of the 2000ms line. Fixed
window lets all of them through. The other two do not. That gap is why fixed window keeps
losing this argument.
10:05:00, the counter resetsminute oneminute two10 at 10:04:5910 at 10:05:0120 in two seconds, limit 10 a minute
Token bucket is the default answer: constant memory per user, and a burst allowance that
matches how real clients behave. Sliding window log is the right answer when the limit is
small and must be exact, such as five login attempts in fifteen minutes.
Now the part that catches people. The algorithm is easy on one machine. With twenty
gateway hosts, every host has to see the same count for the same user.
How do twenty gateway hosts share one limit?
1
First try
Each host counts on its own
Every gateway keeps its own counters in memory. No network, no shared store, nothing to break.
Where it breaks: The load balancer spreads one user across all twenty hosts, and each host allows 100. The real limit is now 2,000 a minute, and it changes every time you add a host.
2
Better
One shared counter: read, compare, write
Keep the counter in Redis. A host reads the count, checks it is under the limit, and writes count plus one back.
Where it breaks: Two hosts can read the same number before either writes. The diagram below plays it step by step.
3
What you ship
One shared counter, changed atomically
Do not read and write separately. Redis INCR adds one and returns the new value in a single step that nothing can interrupt. If the value returned is over the limit, reject. For the token bucket, which updates two numbers, put the read and update in a small Lua script, which Redis also runs as one uninterruptible step.
Here is the second rung going wrong. This bug has a name, the lost update, and the
optimistic vs pessimistic locking
article shows it in a database too.
1/6 Gateway A reads the counter and gets 99. The limit is 100, so A is going to allow this request.
Atomic means "happens as one step, with nothing able to sneak in halfway". The fix is not
a lock. A lock around a counter that every request touches is a throughput disaster and a
new way to fail. The fix is to stop reading and writing separately at all. With INCR, one
round trip both counts the request and tells you where you stand. No read, no race, no lock.
Redis is now a dependency of every request. Sooner or later it will be unreachable for a
minute, and you have to decide in advance what the gateway does. (The
availability
chapter explains why every box in the request path drags your uptime down.)
Redis is unreachable. What does the gateway do with the next request?
1
First try
Fail closed: reject everything
If we cannot count, we cannot be sure, so say 429 to everyone.
Where it breaks: A limiter outage becomes a full API outage. You built a safety feature that takes the whole product down.
2
Better
Fail open: allow everything
If we cannot count, let requests through unlimited until Redis is back.
Where it breaks: The API stays up, but with no limits at all. If the reason Redis is struggling is a traffic spike, that is the worst moment to drop protection.
3
What you ship
Fail open onto a local bucket
Each gateway keeps a token bucket in memory, sized to the global limit divided by the number of hosts. When Redis is gone, the gateway falls back to it. Limits go from exact and shared to approximate and per host, never to nothing. And an alert fires, so someone knows.
The follow up you will get
"What if Redis is down?" Pick a side and say it out loud: fail open, onto a local bucket.
The interviewer is checking whether you see the limiter as a dependency with its own
failure modes, not only as a feature.
Redis is downfail closedevery request 429s:an API outagefail openno limit at alllocal bucketeach host keepsits own limit429
One Redis round trip per request means the counter store now carries your entire traffic.
At 50,000 requests a second that is fine. Drag the dial and watch where it stops being fine.
API traffic
50k/s
50k/s150k/s400k/s400k/s fixed
INCRClientsAPI gatewayx20Redissingle primaryYour service
INCRClientsAPI gatewayx20Redissingle primaryYour service
Healthy. One INCR per request, under a millisecond each, on a single Redis primary. A limiter at this size is a config change, not an architecture.
Read every step as text
50k/s · Healthy. One INCR per request, under a millisecond each, on a single Redis primary. A limiter at this size is a config change, not an architecture.
150k/s · Degraded. A single Redis primary is running out of CPU. Not memory and not network: Redis runs commands one at a time on one core, and that core is full.
400k/s · Down. The limiter is now the slowest thing in your API, adding delay to requests it was going to allow anyway. Every request pays for a check that rejects almost none of them.
400k/s fixed · Healthy. Two changes. Shard the counters by user id, since one user never needs another user's counter. Then let each gateway hold a local token bucket for its share of the limit and only reach Redis when a user gets near the ceiling. Most traffic never touches the counter store at all.
Sharding means splitting the keys across several Redis machines, each owning a slice of
the users (sharding covers it from the start). It works especially
well here because counters never need each other.
The second change is worth stating as a principle. The exact shared count only matters for
users close to their limit. Everyone else can be waved through on local knowledge, and that
is almost everyone.
each user, and how close to their limitlimitRedisnear the line:exact INCRthe rest: local bucket
One moment from this round, answered three ways. Pick the one you think is staff level before
the labels appear. (What changes at senior and staff
explains the gap.)
Pick the staff level answer
InterviewerYour single Redis primary is at 100% CPU at about 150,000 checks a second. What now?
Show which is which without guessing
Answer A: Mid level. Redis runs commands one at a time on one core, so more cores do not help, and the answer never asks what the checks are for.
Answer B: Staff. Uses the number that matters (almost everything is allowed) to remove load instead of spreading it, and ties the cost back to a requirement settled at the start.
Answer C: Senior. Correct, and it scales. Every request still pays a network round trip that almost never changes the answer.
Then run the question cold, with a 45 minute timer and no notes, and score the run on the
five things this question is really testing.
Score your last run
0 / 10
Where it sits
The algorithm
Twenty hosts
Redis goes down
The rejected client
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.
Twenty API hosts, and you want 100 requests per minute per user. Walk me through it.
The word "twenty" is doing the work. This is a shared counting question dressed as a rate limiter.
You
Shared counters in Redis, keyed by user, route and window, with a TTL so cleanup is automatic. Each gateway does a single INCR and rejects if the returned value is over the limit. I would use INCR rather than read, compare, write, because with twenty hosts the read-then-write version races and lets users drift over their limit.
Names the race before being asked. That is the difference between having built one and having read about one.
Interviewer
Fixed window or something else?
There is a right answer and a reason.
You
Token bucket. Fixed window has a boundary problem: a client that packs requests either side of the reset gets close to double the limit, and it is easy to hit by accident with a cron job. Token bucket is two numbers per user, allows a reasonable burst, then paces. Sliding window log is more exact but stores a timestamp per request, which I would only pay for on something like login attempts.
Compares three options in four sentences and commits to one. Listing options without picking is where most answers lose points.
Interviewer
Redis goes down. What happens to your API?
The question is really whether you see the limiter as a dependency rather than a feature.
You
It has to fail open, otherwise a limiter outage becomes a full API outage. But fully open means unlimited traffic, so I would keep a local token bucket in each gateway sized to the global limit divided by the number of hosts. We go from exact shared limits to approximate per host limits. I would also alert on the limiter being in fallback, because otherwise nobody notices.
Picks a side, states the cost, and adds the operational detail that makes it real.
Interviewer
What is the limit for an endpoint you have never seen before?
Open ended. Checking for invented confidence.
You
I would not guess it. I would put the rule in config with a generous starting limit, run it in a log-only mode that records what would have been rejected, and look at the real numbers for a week. Real traffic is lumpier than anyone predicts, and switching a limit straight to enforcing is how you page yourself at 2am over a legitimate batch job.
Shadow mode is the answer someone who has rolled out a limiter gives. It costs nothing to say and is very hard to fake.
1. Why is read-count, compare, write-count wrong across twenty gateway hosts?
2. A client sends 10 requests at 0:59 and 10 more at 1:01 against a limit of 10 per minute. Which algorithm lets all 20 through?
3. The limiter is adding 4ms to every request and rejecting about 0.1% of them. What is the first change you would make?
Worth memorising
50,000 requests a second, one INCR each. Every check sits in the path of a real request.
5 million users x 3 rules is 15 million keys, about 1.4GB. Memory is never the problem here, speed is.
A single Redis primary runs out of its one core somewhere past 100,000 ops a second. Not memory, not network.
99.9% of requests are allowed. That is how much of the round trip bought you nothing.
Say this in 60 seconds
I would put the limiter in the API gateway, before any expensive authentication work, so a rejected request costs almost nothing. Counters live in Redis keyed by user, route and window, with a TTL so there is no cleanup path to get wrong. Each gateway does a single atomic INCR rather than read, compare, write, because with twenty hosts the read-then-write version races and users drift over their limit. The algorithm is token bucket: constant memory per user, a natural burst allowance, and no boundary to game the way fixed window has. If Redis is unavailable I fail open onto a local per host bucket, so a limiter outage loosens limits rather than taking the API down. And when the limiter itself becomes the bottleneck, I shard the counters by user and check locally for everyone who is nowhere near their limit, which is almost everyone.