Why you need a rate limiter, and when to use one
Every public API has a limit on what it can serve. One buggy client in a loop, a scraper or an attacker can use all of it and leave real users with errors. A rate limiter caps how many requests a client may make in a period and rejects the rest, usually with HTTP status 429 Too Many Requests.
It gives you two things interviewers always ask about:
- Availability: one noisy client cannot take the service down for everyone.
- Fairness and cost control: each user gets a share, and expensive calls (login attempts, paid third-party APIs) stay bounded.
Use one when:
- The API is public or used by many tenants.
- Calls are expensive or have a per-user quota (paid plans).
- You need to slow brute-force logins or scraping.
- A downstream service has a hard limit you must respect.
Skip it, or use something else, when:
- Traffic is internal and trusted, and capacity is simply too low: add capacity or a queue instead.
- The problem is a sustained overload from everyone: use load shedding or autoscaling, since a per-client limit does not help.
- You can accept slower responses: a queue absorbs the burst without rejecting.
Try it
Send ten requests under a limit of five per ten ticks, then change the algorithm and the traffic.
Rate Limiter
Step 1 of 12 · Press Space to play, arrow keys to stepInitial state. The rule is at most 5 requests per 10 ticks per client. Requests arrive at ticks 8, 8, 9, 9, 9, 10, 10, 11, 11, 11.
- tick:
- 0
- allowed:
- 0
- rejected:
- 0
- most allowed in any 10 ticks:
- 0
1 tick = one step of the simulation.
Things to try:
- Run Fixed window counter with the burst at the window boundary. The counter resets at tick 10, so ten requests get through in four ticks, double the limit.
- Switch to Sliding window log or Token bucket with the same traffic and compare the most requests allowed in any ten ticks. Expect 5 for the sliding log and 6 for the token bucket: the bucket starts full with five tokens and refills a little before the burst ends. A token bucket removes the reset at the clock edge, but it still allows a burst up to the bucket size plus whatever refills.
- Watch the counters. The counter named tick counts simulation steps, while the rule and the request times are in ticks of time, so on steady traffic one step is not one tick.
- Choose Limiter store is down (fail open) and note that nothing is limited. Then decide whether your service should fail open or closed.
Rate limiting algorithms
| Algorithm | How it works | Good when | Weakness |
|---|---|---|---|
| Fixed window counter | Count requests per clock window, reset at the boundary | You want the simplest, cheapest option | A burst at a boundary can send up to twice the limit |
| Sliding window log | Keep the time of every request and count those in the last window | Accuracy matters and traffic per client is small | Memory grows with the number of requests |
| Sliding window counter | Weight the current and previous window counts | An accurate-enough, cheap compromise | An approximation, not exact |
| Token bucket | A bucket refills at a steady rate and each request takes a token | Bursts are allowed up to a cap | Two settings to tune: rate and size. A burst up to the bucket size still gets through |
| Leaky bucket | Requests enter a queue that drains at a fixed rate (the queue form; the meter form acts like a token bucket, which is how NGINX limit_req works) | You need a smooth output rate | Bursts wait in the queue, adding delay |
Where to put the limiter
- At the edge (API gateway or load balancer): blocks abuse before it reaches your code, but often sees only the key or address, not the user's plan.
- In the application: can limit by user, plan or endpoint, at the cost of every server needing the same counters.
- In a shared store (for example Redis): all servers update the same counters, so the limit is global across the fleet.
Limit by whatever identifies the caller: API key, user ID, or IP address as a fallback. IP addresses are shared by many users behind one network, so they are a weak key.
How the shared counter works
With many servers, each one cannot keep its own count or a client would get the limit multiplied by the number of servers. The common approach is:
- Build a key such as
rl:user42:<window>. - Increment it in Redis and set an expiry on first use. Two commands do this (
INCR, thenEXPIREwithNX, which needs Redis 7.0 or later) and they heal themselves if the second is lost. A script orMULTImakes it strictly atomic, meaning the steps run as one with no other request in between. The expiry is a TTL (time to live): how long Redis keeps the key before deleting it. - If the count is above the limit, reject with 429 and a
Retry-Afterheader. - Return headers that show the remaining quota so clients can slow down.
Use a script or an atomic command, because a separate read and write can race when two servers act at once.
Sizing and scaling a rate limiter
Every check is a round trip to the shared store, so the limiter adds a network hop to each request. The numbers below are rough and illustrative. Measure your own.
- Cost per request: about one store call, often around a millisecond inside one data centre. This is the main cost of a strict global limit.
- Memory: a counter is small. If a key costs around a hundred bytes, a million active clients need roughly a hundred megabytes.
- Throughput: one Redis node can handle very many simple commands a second, but a busy API can outgrow it. Spread the counters across several nodes by hashing the client key.
- Hot keys: one very busy client lands on one node. A small local limit in front of the store absorbs most of that traffic.
- Failover: Redis replication is asynchronous, so after a failover the newest counts may be lost and clients briefly get a fresh allowance. Most services accept this.
- Window resets: if every client retries the moment a window ends, the load arrives in a spike. Add a little random delay to
Retry-After. - Several regions: a single global counter costs a cross-region call. Many systems give each region a share of the limit instead and accept small errors.
- Responses: return 429 when the caller is over its limit and 503 when the server itself is overloaded. Header names vary; many APIs use
X-RateLimit-Limit,X-RateLimit-RemainingandRetry-After.
Rate limiter vs the alternatives
| Concern | Rate limiter | Queue with a worker pool | Autoscaling |
|---|---|---|---|
| Stops one client using everything | Yes | No | No |
| Protects against sudden spikes | Yes, from one client (not from everyone at once) | Yes, by absorbing them | Slowly |
| Keeps every request | No | Yes | Yes |
| Cost of extra capacity | None, apart from the store call | Some | High |
| Adds delay to accepted requests | None | Yes | None |
| Best for | Abuse and fairness | Work that can wait | Real growth |
Use a rate limiter for fairness and abuse, a queue when work can wait, and autoscaling when genuine demand grows.
Avoiding a new single point of failure
The limiter's store is now on the path of every request. Decide what happens when it fails:
- Fail open: let requests through. The service stays up but is unprotected.
- Fail closed: reject requests. The service is protected but unavailable.
A common choice is to fail open for general traffic and fail closed for sensitive endpoints such as login. Also keep a small in-memory local limit as a fallback, replicate the store, and set a short timeout so a slow store does not slow every request.
Common mistakes
- Per-server counters. A client gets the limit times the number of servers.
- Separate read then write. Two requests can both pass. Use an atomic operation.
- Limiting only by IP. Shared networks get blocked together, and attackers rotate addresses.
- No
Retry-Afterheader. Clients retry at once and make it worse. - Same limit for every endpoint. Login and search need different limits.
- Forgetting the store failure case. Decide fail open or closed on purpose.
How to talk about a rate limiter in an interview
- Ask who is limited (user, key, IP), where the limiter lives and how strict it must be.
- Pick an algorithm and say why: token bucket to allow bursts, sliding window for accuracy.
- Say that counters live in a shared store with atomic updates, and return 429 with Retry-After.
- Name the trade-off: strict global limits cost a network call per request, so many systems accept small errors in exchange for speed.
Further learning
- Caching: the same shared-store pattern, with eviction and invalidation.
- Load balancer: another component on the path of every request, with its own failure modes.