System Design Bits

Support the project

System Design Bits is free and always will be. If it helped, a coffee helps back.

Buy Me a Coffee QR codeBuy me a coffee

Concept 3 · Communication and APIs·19 min read

Rate Limiting

Why the algorithm behind a limit decides how forgiving it is, not just what number is printed on it.

Big Idea

Rate limiting caps how often a client can call your system in a given window, and the algorithm you pick decides how forgiving that cap is about bursts, not just what the average rate ends up being. Two limiters advertising “100 requests a minute” can behave completely differently in the second that actually matters.

Why This Exists

Why One Client Can Become Everyone’s Outage

▸

A single misbehaving client, whether a buggy retry loop or a deliberate scraper, can send requests fast enough to consume the capacity that every other client needs, turning one bad actor's mistake into an outage for everyone.

▸

Without a limit, a spike in legitimate traffic, a sale going live or a post going viral, can overwhelm downstream databases and services that were sized for normal load, causing cascading failures well beyond the endpoint that got hit first.

▸

APIs that charge per request or offer tiered plans have no way to enforce those tiers, or to give paying customers a reliable share of capacity, without some mechanism that actually counts and caps usage per client.

Think Of It Like

A Data Plan, And The Night Before It Resets

Rate limiting is like the data allowance on a phone plan. The carrier isn’t trying to stop you using the network, it’s keeping any one phone from eating the capacity of a tower everybody is sharing.

A fixed window plan gives you 10 GB that resets at midnight on the first of the month, which is why someone who checks their usage on the 31st can burn the last 10 GB that night and another 10 GB the next morning: twenty gigabytes in a few hours, out of a plan sized for ten a month. A plan with rollover data behaves like a token bucket instead. The allowance accrues steadily, stops accruing at some ceiling, and a quiet month lets you spend a burst later without ever beating the long-run rate. Same number on the bill, very different night for the tower.

The Shape Of It

A Layered Limiter In Front Of An API

Cheap and coarse at the edge, precise and shared deeper in

A layered rate limiter in front of an APIClientrequestCDNedgecoarse limit passedAPI gatewaythe rate limiterallowed requestApp serverRediscounters / tokenscheck and update the count
The counter store sits beside the limiter, not inside it. That one detail is what lets ten gateway instances enforce a single shared limit, and it is also the detail most local implementations quietly skip, which is why their limit turns out to be ten times what the documentation claims.

Anatomy

The Six Pieces

Limit

The ceiling itself: how many requests are allowed. A number with no window attached means nothing.

Window

The span the limit applies over, per second, per minute, per day. The limit and the window are one decision, not two.

Key

What the counter is kept per: an IP, an API key, a user ID, often a pairing of one of those with a specific endpoint.

Counter store

Where the count or token balance lives. A single process can hold it in memory; a fleet of servers needs a shared store.

Enforcement point

Where the check actually runs: at the CDN, at the API gateway, or inside the service handling the request.

Response

What a rejected client is told. A limit the client can't see is a limit it can't cooperate with.

The key is the piece people underthink. Limiting by IP, by API key, by user, or by the pair of user and endpoint are four different products: they decide who gets throttled together. Everything else on this list is arithmetic, but the key is a policy decision about fairness.

The Concept

Four Algorithms And What Each One Forgives

1
Token bucket

A bucket holds up to N tokens and refills at a steady rate; each request consumes one token, and requests are rejected once the bucket is empty. It naturally allows short bursts up to the bucket's capacity while enforcing a steady average rate over time, which is why it's the most commonly used algorithm in practice.

2
Leaky bucket

Requests enter a queue, the bucket, and are processed, or leak out, at a strictly constant rate regardless of how bursty the incoming traffic is. It smooths bursts into a steady output stream, which protects downstream systems that genuinely can't handle spikes, at the cost of added latency for queued requests during a burst.

3
Fixed window counter

Count requests in a fixed time window, like 'this minute', and reset the counter to zero at each window boundary. It's simple to implement and reason about, but has a real burst problem at the edges: a client can send the full limit right at the end of one window and the full limit again at the start of the next, doubling the effective rate for a brief moment.

4
Sliding window, log or counter

Instead of resetting sharply at a boundary, the window slides continuously, either logging every request's timestamp or interpolating between the current and previous fixed windows. This removes the boundary-doubling problem at the cost of more memory, for the log, or a small approximation error, for the counter.

5
It protects two different things at once

The server itself, from being overwhelmed past its capacity, and other clients, from one noisy or abusive client starving everyone else's fair share of a shared resource. A limit set only with the server's CPU in mind can still let one client monopolize the queue ahead of everybody else.

6
Where the limit is enforced matters

Per-IP limiting is easy but breaks down behind NATs or shared corporate addresses where many real users look like one client. Per-API-key or per-user-account limiting is far more precise, but it requires authentication to already have happened before the limit can apply, which is why the two usually sit in layers rather than competing.

7
A limiter has to say so, out loud

A well-designed limiter returns a clear signal rather than a silent drop: an HTTP 429 status, plus headers like Retry-After or X-RateLimit-Remaining, so a well-behaved client knows it was throttled and exactly when it's safe to try again instead of guessing and retrying blindly.

8
Distributed enforcement needs a shared store

When the limit has to hold across many API server instances rather than one process, counts or token balances live in a fast shared store like Redis. Each server enforcing its own local limit independently would let a client multiply the effective limit by the number of servers, which is the single most common way a correct-looking limiter turns out not to limit anything.

9
Limits are layered, not singular

A coarse limit at the edge, a CDN or API gateway, blocks obvious abuse cheaply, before it reaches application servers. A finer per-endpoint or per-user limit deeper in the stack protects specific expensive operations, like search or file upload, far more tightly than a cheap health-check endpoint needs.

Key Terms

The Vocabulary Of Saying No Politely

Token bucket

An algorithm where a bucket refills tokens at a steady rate and each request consumes one, allowing bursts up to the bucket's capacity while capping the long-run average rate.

Leaky bucket

An algorithm that queues incoming requests and processes them at a strictly constant output rate, smoothing bursty input into steady downstream load.

Fixed window counter

An approach that counts requests within a fixed time slice and resets to zero at each boundary, simple but vulnerable to a burst doubling right at the window edge.

Sliding window

An approach that avoids the fixed window's edge-burst problem by continuously sliding the counted window forward, either by logging exact timestamps or interpolating between adjacent windows.

Rate limit key

The identity a counter is tracked against. Choosing it is choosing who gets grouped together, and therefore who gets throttled together.

429 Too Many Requests

The standard HTTP status returned when a client has exceeded its limit, ideally paired with a Retry-After header telling the client when to try again.

Retry-After

The response header naming the delay, in seconds or as a date, before a retry will be accepted. It's the difference between a client backing off correctly and guessing.

Distributed rate limiting

Enforcing a single limit consistently across many server instances by tracking counts in a shared store, instead of each server enforcing its own local, inconsistent limit.

Worked Example

Token Bucket vs Leaky Bucket

Token bucket
●

Tokens refill at a steady rate, up to a maximum bucket size

●

Allows bursts up to the bucket's capacity

●

Rejects a request outright once the tokens run out

Limit of 10/s, bucket of 50
100 requests arrive at once
served from the bucket50
rejected with 42950
refill, the next second+10 served

A client that has been idle has a full bucket, so it can spend all 50 in a single instant. The long-run average holds at 10 a second, but the downstream service still sees a spike of 50.

VS
Leaky bucket
●

Requests queue and are released at a constant output rate

●

Smooths bursts into steady, predictable downstream load

●

Queued requests wait, which adds latency during a burst

Limit of 10/s, bucket of 50
100 requests arrive at once
queued, the bucket holds 5050
dropped, the queue is full50
output rate10/s, flat

Downstream never sees more than 10 a second, which is the point. But the 50th queued request waits five seconds for a response the client may have already given up waiting for.

Both columns enforce the same average. The difference is who absorbs the burst: the token bucket pushes it downstream and tells the excess clients no immediately, while the leaky bucket absorbs it in a queue and makes the clients wait instead. Pick by asking which your downstream can survive, a spike or a delay.

The Shape Of It

Token Bucket, One Request At A Time

The refill runs on the clock, not on the request

Token bucket, per requestRequest arrivesToken available?yesConsume tokenrequest allowednoReject: 429with Retry-AfterBucket refillssteady rate, up to N
Nothing here depends on how long ago the last request was, only on what the clock has put back in the bucket since. That is the whole trick: an idle client accumulates headroom up to the cap and no further, so the average holds without anyone tracking a window at all.

Failure Mode

The Fixed Window’s Boundary Burst

A limit of 100 per window, and a client that knows where the boundary is

Fixed window boundary burst versus a sliding window0100200556065window resetsTime (seconds)Requests allowedFixed window: 200 in two secondsSliding window: 100, same limit

Both lines are enforcing “100 per minute” and both are doing exactly what they were configured to do. The client sends its full allowance at second 59 and its full allowance again at second 60, and the fixed window counter has no memory of the window it just closed, so it permits 200 requests inside a two-second span. If the server was sized for 100 a minute, it just received a minute and a half of traffic in two seconds.

A sliding window log fixes this exactly by keeping every request’s timestamp and counting only those inside the last sixty seconds, which costs one stored timestamp per request. The sliding window counter gets most of the way there far more cheaply by keeping just two counters and weighting the previous window by how much of it is still in view: a quarter of the way into the current window, it counts the current window’s requests plus three quarters of the previous one’s. It approximates, but it never has a boundary to exploit.

Reference

Which Algorithm Fits Which Job

AlgorithmAllows BurstsWhat It StoresWhat Defines ItWhere You've Met It
Token bucketYes, up to capacityA balance and a timestamp per keyThe general-purpose default: steady average, forgiving of a short burstMost APIs you've used: a few calls in a row are fine, hammering it for a minute isn't
Leaky bucketNo, smoothed flatA queue per keyConstant output rate; bursts wait rather than being rejected immediatelyDropping twenty photos into an upload: they all go through, one at a time, at a steady pace
Fixed window counterYes, at the boundaryOne counter per key, per windowSimplest thing that works, and up to 2x the limit around a resetYour card's daily cash limit: it resets at midnight, so late tonight plus early tomorrow is twice the cap
Sliding window logNoOne timestamp per requestExact, and the most expensive to store at high volumeThree wrong PIN tries and you're locked out, where the count has to be exactly right
Sliding window counterBarelyTwo counters per keyWeighted blend of the current and previous window; approximate but cheapA huge site keeping scrapers out without keeping a record of every single visit
The choice is rarely about accuracy. It is about what you want a burst to do: pass through up to a cap (token bucket), wait in line (leaky bucket), or be measured against a window that can’t be gamed (sliding). Default to token bucket, and reach for the others when you can name the specific thing it forgives that you can’t afford.

Failure Mode

One True Limit Across Many Servers

A limiter that works perfectly on one server often stops limiting anything the moment that server becomes five. The load balancer spreads one client’s requests across the fleet, so each instance sees only a fraction of that client’s traffic and happily counts it as well under the limit.

The arithmetic is blunt: a local limit of 100 a minute, enforced independently on five instances, is a real limit of 500. Nothing in the logs looks wrong, every instance reports it is enforcing the documented number, and the client sees five times what it was promised.

The fix is a shared store, usually Redis, holding the counter or token balance centrally. That introduces two new problems worth deciding about in advance rather than during an incident. The first is that the check now costs a network round trip on every single request, which is why limiters keep the operation to one atomic increment and put the store in the same region as the gateway.

The second is the question nobody enjoys: what happens when the counter store is unreachable? Failing open lets all traffic through and risks the overload the limiter exists to prevent. Failing closed rejects everything and turns a cache outage into a total outage. Most systems fail open with a coarse local fallback limit, so a store outage degrades precision rather than availability, but the point is that it is a decision, not a default.

Counterpoint

Who The Limit Is Actually Counting

Per-IP limiting is the version everyone reaches for first, because it needs nothing from the client and works before authentication. It is also the version that gets both halves of the job wrong. One office, one university, or one mobile carrier gateway can put thousands of unrelated users behind a single address, so the limit throttles them as though they were one client. Meanwhile an attacker with a few hundred addresses stays comfortably under a per-IP limit while sending a few hundred times the traffic.

Per-API-key and per-account limits fix the fairness problem, because the key is the actual identity, but they can only be applied after the request has been authenticated, which means unauthenticated endpoints like login and signup still need an IP-shaped limit in front of them. That is the real reason limits end up layered rather than unified: a coarse IP limit at the edge protects the door, and a precise per-account limit inside protects the expensive rooms.

And one limit for every endpoint is its own mistake. A search query that fans out across a database and a health check that returns a constant should not share a budget. Price the limit by what the operation costs you, not by what is easiest to configure.

Seen In The Wild

Limits You’ve Probably Hit

▪

Stripe's API enforces per-account rate limits and returns clear 429 responses with guidance, documented explicitly so integrators can build correct retry logic instead of guessing at the behaviour.

▪

GitHub's REST and GraphQL APIs use a token-bucket-style limit tied to your authentication method, with response headers reporting exactly how many requests remain in the current window.

▪

Cloudflare offers rate limiting as an edge product, letting sites throttle or block abusive traffic patterns before a single request ever reaches the origin server.

▪

Twitter/X has long enforced strict per-endpoint limits on its API, historically a major constraint that shaped how third-party clients and bots were built around it.

Key Points

What To Carry Forward

✓

Token bucket allows controlled bursts up to a cap while enforcing a steady average rate, and is the most common general-purpose choice.

✓

Leaky bucket smooths bursty traffic into a constant output rate by queueing, protecting downstream systems that can't handle spikes at all.

✓

Fixed window counters are simple but allow nearly double the limit at the window boundary; sliding windows fix that for more memory or a small approximation.

✓

Rate limiting protects the server's own capacity and other clients' fair share of it, not just one or the other.

✓

A fleet of servers needs a shared store like Redis to enforce one true limit, or the limit multiplies by the number of instances.

✓

A good limiter communicates: 429 plus retry timing, instead of silently dropping requests and leaving the client to guess.

✓

The rate limit key decides who gets throttled together, which is why per-IP alone punishes shared networks and misses distributed abuse.

✓

Limits belong in layers, cheap and coarse at the edge, tight and specific on the expensive endpoints.

Common Mistakes

Where This Usually Goes Wrong

✕

Implementing a naive fixed window counter without realizing it allows nearly double the intended rate right at the window boundary.

✕

Rate limiting only by IP address, which unfairly throttles many legitimate users sharing a NAT or corporate gateway while barely slowing down a distributed attacker.

✕

Enforcing the limit locally per server instance in a multi-server deployment, letting a client's effective limit multiply by however many servers happen to receive its requests.

✕

Returning a bare rejection with no Retry-After or remaining-quota information, leaving well-behaved clients to retry blindly instead of backing off correctly.

✕

Setting one blanket limit for every endpoint, instead of tighter limits on expensive operations like search or file upload and looser ones on cheap operations like a health check.

✕

Rejecting with a 500 or a connection reset instead of a 429, so clients and their monitoring read a deliberate throttle as a server fault and retry harder.

✕

Treating the limiter itself as infallible, with no decision made about what happens to traffic when the shared counter store is unreachable.

“Three treats a day, my human says. But there are four humans in this house, none of them compare notes, and the paw-on-the-knee routine works on each of them separately, so my real limit is twelve. My harness is getting snug. Nobody has proposed a shared counter, and I am absolutely not going to be the one who brings it up.”

Try It Yourself

Guess The Algorithm Behind A Limit You’ve Hit

Pick an API you’ve used, or just the app you use most. From its documented behaviour, guess which algorithm it likely uses: does it allow a short burst, or is the rate perfectly smooth? Then sketch, in one sentence each, how a fixed window counter and a token bucket would behave differently if you sent 100 requests in the first second of every minute, every minute, for an hour.

Quiz Review

Check your understanding

Question 1 of 5

Why does a fixed window counter allow more than the stated limit in practice, and how does a sliding window fix it?

  • ✓A fixed window resets sharply at each boundary, so a client can send the full limit in the last moment of one window and the full limit again in the first moment of the next, achieving nearly double the intended rate in a short span around the boundary. A sliding window avoids this by never having a hard reset point, either tracking exact timestamps or smoothly weighting between the current and previous window, so no short span of time ever sees more than the intended limit.