Most outages caused by a single customer are not attacks. They are a retry loop. Something
times out, a client retries, the retry times out because the first request is still
holding a connection, and within a minute one integration is sending forty times its
normal traffic at you. The customer does not know. Your pager knows.
Rate limiter / at a glance
A rate limiter is the piece that turns that from an outage into a 429. It is a small
system with a surprising amount of depth, which is why it shows up so often in interviews.
A rate limiter looks like one number in a config file, so people start drawing
immediately. Ask these first. Two of the six answers below decide the entire 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 different limit on expensive routes.
The shape of the counter key, rl:{user}:{route}:{window}. One user calling two routes needs two counters, which is what makes the key count large.
How many API hosts share one limit?
Twenty, behind a load balancer.
The whole problem. One host counting on its own is not a rate limiter, so the counter has to live outside the process and every host has 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 take the network off the hot path when the limiter itself becomes the bottleneck. If the answer had been "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 jitter. A limiter that rejects silently causes the retry storm it exists to prevent.
Can limits change without a deploy?
Yes, during an incident, in seconds.
Rules live in watched config and the gateway hot reloads them. It also means a bad config change is now a way to take yourself 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. Settling this in minute three is much better than being asked it as a gotcha in minute forty.
on the counter store, and every one of them is on the hot path
1.4GB fits in memory on one machine, so this is a throughput problem rather than a capacity problem. That is what pushes the design toward a single round trip per request and nothing clever.
The response headers matter more than the endpoint list. 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 well behaved client slows itself down as Remaining falls, which is far cheaper for both sides than finding the wall at full speed.
GETthe same route, over limit
returns 429 with Retry-After: 17
Why: Retry-After in seconds, and jitter it per client. Without jitter every rejected client comes back at the same instant and you get a thundering herd on a schedule you built yourself.
Redis keyspaceone key per user per rule per window
key
string
PK
rl:{userId}:{route}:{windowStart}
value
integer
Request count in this window. Incremented, never read 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. Getting this wrong is how a limiter turns into a memory leak.
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 two dashed boxes are what keeps the limiter from becoming a single point of failure.
Walking Figure 1:
The request lands on any gateway host. They 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-process bucket first. For the overwhelming majority of users, who
are nowhere near their ceiling, the answer is already known here and the request never
leaves the box.
Only users close to a limit cost a network call. That is one atomic INCR against
Redis, sharded by user id, and the returned value is the decision.
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 429 rate per route and an alert on
the gateway running in local-only fallback, because otherwise nobody notices the day
your limits quietly stopped being global.
Two placement decisions are worth defending out loud. The limiter runs in the gateway
rather than in each service, so one counter covers every route and a rejected request
never costs a service call. And it runs after cheap identification but before expensive
authentication, because verifying a token against a database on a request you are about
to reject is exactly the work you were trying to avoid.
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?
This is the part of the question with a right answer, and the difference is invisible
until you watch the same burst scored three ways. The traffic below 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 the entire reason fixed
window keeps losing this argument, and it is not theoretical. A client whose cron job
happens to land on your boundary gets close to twice its limit, forever, and nothing
anywhere reports an error.
Token bucket is the default answer. Constant memory per user, a burst allowance that
matches how clients really behave, and two integers to store. Sliding window log is the
right answer when the limit is small and exactness matters, such as login attempts.
The race that makes it a distributed systems problem#
Now the part that catches people. Twenty gateway hosts share one counter. The obvious
implementation reads the count, compares it against the limit, and writes the count back.
1/6 Gateway A reads the counter and gets 99. The limit is 100, so A is going to allow this request.
The fix is not a lock. A lock around a counter on the hot path of every request is a
throughput disaster and a new failure mode. The fix is to stop reading and writing at all.
INCR in Redis is atomic and returns the new value, so one round trip both counts the
request and tells you where you stand. The check becomes: increment, and if the returned
value is above the limit, reject. No read, no race, no lock. When you need more than one
operation to stay atomic, such as an increment plus a conditional expiry, put both in a
Lua script, which Redis runs as a single atomic unit.
The follow up you will get
"What if Redis is down?" Decide this deliberately and say it out loud. Failing open lets
traffic through unlimited, which risks the outage the limiter exists to prevent. Failing
closed rejects everyone, which turns a limiter outage into a full outage. The usual
answer is fail open with a local in-process limiter as a floor, so you degrade from
precise global limits to approximate per host ones instead of to nothing.
One Redis round trip per request means the counter store now takes 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, sub millisecond, 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, sub millisecond, 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 single thread. It is not memory and it is not the network, it is that Redis executes commands on one core.
400k/s · Down. The limiter is now the slowest thing in your API and it is adding latency 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 keys are independent and there is nothing to coordinate between them. Then let each gateway hold a local token bucket for its share of the limit and only reach Redis when a user is anywhere near their ceiling. Most traffic never touches the counter store at all.
That last step is worth stating as a principle. The exact global count only matters for
users close to their limit. Everyone else can be waved through on approximate local
knowledge, and that is almost everyone.
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 distributed counting question wearing a rate limiter costume.
You
Shared counters in Redis, keyed by user and 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 specifically 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 have thought about the limiter as a dependency rather than a feature.
You
It has to fail open, otherwise a limiter outage becomes a full API outage, which is a strictly worse trade. But failing fully open means unlimited traffic, so I would keep a local token bucket in each gateway sized to the global limit divided by host count. We degrade from exact global limits to approximate per host limits. I would also alert on the limiter being in fallback, because right now 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 from first principles. 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 actual distribution for a week. Real client traffic is much lumpier than anyone predicts, and shipping 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 it 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 on the hot path.
5 million users x 3 rules is 15 million keys, about 1.4GB. Memory is never the problem here, throughput is.
A single Redis primary runs out of 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. Algorithm is token bucket: constant memory per user, allows a natural burst, and no boundary to game the way fixed window has. If Redis is unavailable I fail open onto a local per host bucket, since a limiter outage should degrade limits rather than take the API down. And when the limiter itself becomes the bottleneck, the fix is to check locally for everyone who is nowhere near their limit, which is almost everyone.