Library
28 min read

Design a rate limiter

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.

ClientLoad balancerAPI gatewayLimit configLocal bucketRedis clusterAuth serviceYour service

Arrive. A request from one user reaches the load balancer.

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.

Step 1: Understand the problem#

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

What you are building#

Functional requirements
What the system does.
  • 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.
Non-functional requirements
How well it has to do it. The numbers you commit to.
  • Adds under 5ms at p99 to a request (99 of every 100 requests pay less than that).
  • 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.
Out of scope
What you cut, and say so out loud.
  • DDoS protection. Floods of junk traffic are absorbed at the network edge before they ever reach an application limiter.
  • Billing quotas. Similar counting, very different rules, because money has to be exact and a rate limit does not.
  • Fair queueing between tenants. Worth naming as the next step up, out of scope for 45 minutes.

Back of the envelope#

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.

Step 2: Propose the high level design#

The core entities
  • RuleA limit per user, API key or route, like 100 requests a minute. Read from config and cached on every gateway.
  • CounterOne number per user, route and window in Redis, with a TTL so it cleans itself up.
  • DecisionAllow, or a 429 with Retry-After and the X-RateLimit headers.
Never breaks: The limiter never takes the API down with it. When it fails, it fails open.

The contract#

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

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
keystringPKrl:{userId}:{route}:{windowStart}
valueintegerRequests counted in this window. Only ever incremented, never read and 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. Forgetting the TTL is how a limiter turns into a memory leak.

The whole system on one whiteboard#

Figure 1 is the same path as the picture at the top, with the metrics box added.

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:

  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.
  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-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.
  4. 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.)
  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 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?

Step 3: Design deep dive#

How to count: three algorithms#

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.

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.

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.

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.

Twenty hosts, one count#

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.

When Redis goes down#

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.

Break it: the limiter becomes the bottleneck#

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
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
  1. 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.
  2. 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.
  3. 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.
  4. 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.

Trade-offs#

ChoiceWhat you gainWhat you payPick it when
Token bucketTwo numbers per user, allows a natural burst, then paces strictly.A client can always burst up to the bucket size, so the service behind it 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 grows with the limit for every active user, plus trimming work on each request.Small limits where precision matters, like five login attempts in fifteen minutes.
Shared counters in RedisOne true count across every host, so 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, Redis only near the limitNo round trip on the common path, and the limiter cannot take the API down.Limits become approximate, and a user spread across hosts can briefly go over the shared limit.Very high traffic, or when the limiter must never be in the critical path.

The level bar#

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
  1. Where it sits
  2. The algorithm
  3. Twenty hosts
  4. Redis goes down
  5. The rejected client
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.

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

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