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.

Before you scroll

Before you scroll: you run 20 API hosts behind a load balancer and want to allow each user 100 requests a minute. Where do you keep the count, and what goes wrong with the obvious answer?

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.

The numbers

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.

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 design

Figure 1. The limiter sits in the gateway, before authentication does any expensive work and before anything touches a database.

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.

Deep dive one: 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, indefinitely, 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.

Deep dive two: 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.

Deep dive three: the limiter itself 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?

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.