LearnHLDDesign a rate limiter

Design a rate limiter

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.

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.

Step 1: Understand the problem

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 askThey sayWhat 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.

What you are building

In scope
  • Limit requests per user, per API key and per endpoint. Different limits for different routes, since a search is not a login.
  • Return 429 with a Retry-After header. A client that cannot tell when to come back will hammer you.
  • Work correctly across every API host. This is the whole problem. One host counting alone is not a rate limiter.
The numbers you commit to
  • Adds under 5ms at p99 to a request.
  • A limiter failure must not take down the API.
  • 50,000 requests a second at peak across 20 hosts.
  • Limits change at runtime without a deploy.
Cut, and say so out loud
  • DDoS protection. Volumetric attacks are absorbed at the edge before they ever reach an application limiter.
  • Billing quotas. Similar counting, completely different consistency requirement, because money has to be exact and a rate limit does not.
  • Per-tenant fair queueing. Worth mentioning as the next step up, out of scope for 45 minutes.

Back of the envelope

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

Step 2: Propose the high level design

The contract

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.
PUT/internal/limits/{tier}
{
  "tier": "free",
  "rules": [
    { "route": "POST /search", "limit": 20, "window": 60 },
    { "route": "*",            "limit": 100, "window": 60 }
  ]
}
Why: Limits live in config, not in code. During an incident you want to tighten a limit in seconds, and a deploy is not seconds.

Where the counters live

Redis keyspaceone key per user per rule per window
keystringPKrl:{userId}:{route}:{windowStart}
valueintegerRequest count in this window. Incremented, never read then written.
ttlsecondsIDXWindow 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.

The whole system on one whiteboard

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:

  1. The request lands on any gateway host. They are interchangeable, which is exactly why the count cannot live inside one of them.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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?

Step 3: Design deep dive

The three algorithms, on the same traffic

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.

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.

Break it: the limiter becomes the bottleneck

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
Healthy. One INCR per request, sub millisecond, single Redis primary. A limiter at this size is a config change, not an architecture.

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.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Token bucketTwo integers per user, allows a natural burst, then paces strictly.A client can always burst up to the bucket size, so downstream has to survive that.The default for API rate limiting. Pick this unless you have a specific reason not to.
Sliding window logExactly correct with no boundary to game.Memory proportional to the limit for every active user, plus trimming work on each request.Small limits where precision matters, like five login attempts in fifteen minutes.
Centralised counters in RedisOne true count across every host, and limits are exact.A network round trip on every request and a shared dependency that can take your API with it.Up to low hundreds of thousands of requests a second, which is most systems.
Local buckets with periodic syncNo round trip on the common path, and the limiter cannot take the API down.Limits become approximate, and a user spread across hosts can exceed the global limit briefly.Very high traffic, or when the limiter must never be in the critical path.

Interview replay

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

Checkpoint

Checkpoint

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.