Rate Limit Algorithms
Fixed window, sliding window, token bucket and leaky bucket — what each one allows, what it refuses, and the burst each permits.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
Which counting algorithm should enforce my limit, and what does each one actually allow through?
The API allows a documented number of requests per minute per key. Clients with legitimate short bursts should not be punished, and nobody should be able to send double the limit by timing it right.
Count requests in the current minute. When the minute rolls over, reset the counter to zero.
A client sends its full allowance in the last instant of one window and its full allowance in the first instant of the next: twice the limit inside a period shorter than one window, entirely within the rules.
- A client sends its full allowance in the last instant of one window and its full allowance in the first instant of the next: twice the limit inside a period shorter than one window, entirely within the rules.
- The reset is a cliff. Every client discovers capacity at the same instant, so traffic arrives in a spike at each boundary (Thundering Herd in Concurrency).
- A client that spreads requests evenly and one that fires them all at once are treated identically, even though only one of them is a capacity problem.
- Increasing window length reduces the boundary problem and makes the burst larger; shortening it does the reverse. There is no window length that fixes it.
What is actually happening
- Fixed window counts requests per aligned interval and resets. It is the cheapest — one counter and one expiry per key — and its defect is structural: because the boundary is a discontinuity, up to 2x the limit can pass within one window's duration straddling the boundary.
- Sliding window log stores a timestamp per request and counts those inside the trailing window. It is exact, allows no boundary burst, and costs memory proportional to the limit for every key.
- Sliding window counter approximates the log by keeping the current and previous fixed-window counts and weighting the previous one by how much of it the trailing window still covers. Two counters per key, no boundary cliff, and an approximation that assumes traffic was evenly distributed in the previous window.
- Token bucket holds a bucket of capacity B refilled at rate R. A request takes a token or is refused. It permits a controlled burst of up to B while enforcing the long-run average R — which is why it fits APIs whose clients are naturally bursty.
- Leaky bucket models a queue draining at a constant rate. Arrivals join the queue if there is room and are refused otherwise; departures are perfectly smooth. It shapes traffic rather than merely limiting it, at the cost of adding queueing delay.
- The distinction that matters most in practice: token bucket allows bursts up to the bucket size; leaky bucket removes bursts by smoothing output. One protects an average, the other protects an instantaneous rate.
- Every one of these is a read-modify-write on shared state. Across instances they are correct only if the whole operation is atomic — a token bucket in particular reads tokens, computes the refill, decrements and writes, and all four must be indivisible (Rate Limiting).
Four algorithms, four burst behaviours
The column that decides most choices is the burst one. Everything else — memory, exactness, smoothness — follows from how each algorithm treats time, and none of them is the best on every axis.
Read the fixed-window row carefully: the 2x boundary burst is not an implementation defect that a better version fixes. It is inherent to resetting a counter at a discontinuity, and the only remedies are a different algorithm or a window short enough that 2x is tolerable.
| Algorithm | State per key | Burst it allows | Best for | Main cost |
|---|---|---|---|---|
| Fixed window | One counter + expiry | Up to 2x the limit across a boundary | Cheap coarse limits, internal services, edge backstops | The boundary burst, and a synchronised spike at each reset |
| Sliding window log | A timestamp per request | None — exact | Small limits where exactness is required | Memory proportional to limit times active keys |
| Sliding window counter | Two counters | Minimal; slight approximation error | Public APIs needing accuracy without per-request history | Assumes the previous window was evenly distributed |
| Token bucket | Token count + last refill time | Up to the bucket size, deliberately | Client-facing APIs with naturally bursty clients | Downstream must absorb a full-bucket burst |
| Leaky bucket | Queue depth + last drain time | None — output is constant rate | Protecting a downstream with a hard instantaneous limit | Adds queueing delay; clients wait rather than fail fast |
The boundary burst, concretely
This is the one behaviour worth being able to draw from memory, because it is the gap between the limit you documented and the limit you enforce. With a limit of 100 per minute, a client sends 100 requests just before the boundary and 100 just after: 200 requests inside a span far shorter than a minute, without breaking a single rule.
Whether that matters is a real question. For a coarse internal limit, 2x is noise. For an authentication endpoint or a downstream with a hard capacity ceiling, 2x is the difference between a limit and a suggestion.
A token bucket that is correct under concurrency
Token bucket is the most common choice for a public API, and the most common place to get the atomicity wrong. The check involves reading the token count, computing how many have refilled since the last update, decrementing, and writing both values back — four steps that must be one.
The version below pushes all four into the store so no interleaving is possible, and takes the current time from the store rather than from the application, which removes clock skew between instances from the correctness argument.
1// Executed server-side in the store, so read-refill-decrement-write2// cannot interleave with another instance doing the same thing.3// TIME comes from the store: instances with skewed clocks would4// otherwise credit different amounts of refill.5const TOKEN_BUCKET = `6 local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'updated')7 local capacity = tonumber(ARGV[1]) -- burst we are willing to absorb8 local refill = tonumber(ARGV[2]) -- tokens per second = sustained rate9 local cost = tonumber(ARGV[3]) -- cost-weighted: not every call is 110 local now = tonumber(redis.call('TIME')[1])11 12 local tokens = tonumber(bucket[1]) or capacity13 local updated = tonumber(bucket[2]) or now14 15 -- refill for elapsed time, never above capacity16 tokens = math.min(capacity, tokens + (now - updated) * refill)17 18 if tokens < cost then19 -- how long until enough tokens exist: a real Retry-After,20 -- not a constant we guessed21 local wait = math.ceil((cost - tokens) / refill)22 return { 0, tokens, wait }23 end24 25 tokens = tokens - cost26 redis.call('HMSET', KEYS[1], 'tokens', tokens, 'updated', now)27 redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill) * 2)28 return { 1, tokens, 0 }29`30 31async function allow(key: string, cost = 1) {32 const [ok, remaining, retryAfter] = await redis.eval(33 TOKEN_BUCKET, 1, `rl:${key}`, CAPACITY, REFILL_PER_SEC, cost,34 )35 return { allowed: ok === 1, remaining, retryAfter }36}Three things are doing work beyond the arithmetic. The store's clock removes instance skew from the refill calculation. The expiry bounds key growth so the limiter cannot leak memory across every key it has ever seen. And cost is what lets one expensive endpoint charge more than one cheap one against the same bucket.
How to build it
Most important first.
- Default to token bucket for client-facing APIs: it accommodates the burstiness real clients have while bounding the sustained rate, and its two parameters map to a sentence a customer can understand.
- Use fixed window where the limit is coarse, the traffic is not adversarial and the cost matters more than precision — internal services, cheap endpoints, coarse edge protection.
- Use sliding window counter when the boundary burst is unacceptable but a per-request log is too expensive, which is the common case for a public API.
- Use a sliding window log only when exactness is required and the limit is small enough that storing a timestamp per request is affordable.
- Use leaky bucket when the downstream cannot tolerate bursts at all — a dependency with a hard instantaneous rate, or an outbound integration whose provider throttles sharply (Email and Notifications).
- Size a token bucket from two separate facts: the refill rate is the sustained throughput you will support, and the bucket size is the largest burst you are willing to absorb. They are independent decisions.
- Implement the whole check as one atomic operation in the shared store — a server-side script, a single conditional update, or a compare-and-set loop (Atomic Operations).
- Return the algorithm's own view to the client: remaining allowance, reset time, and
Retry-Aftercomputed from the actual state rather than a constant (The Rate-Limit Contract in API Design).
What can go wrong
- Fixed window chosen without knowing about the boundary burst, so the documented limit is not the enforced one.
- Sliding window log applied to a large limit, so a single key stores a very large number of timestamps and the limiter becomes a memory problem.
- Token bucket refill computed with the application's clock rather than the store's, so instances with skewed clocks credit different amounts (Backend Races).
- Refill and consume as separate operations, so concurrent requests double-credit tokens.
- Leaky bucket used where the client expects immediate rejection, so requests sit in a queue and clients time out instead of receiving a clean 429 (Timeouts).
- Bucket size accidentally equal to the per-window limit, which reproduces the fixed-window burst in a different shape.
- Counters that never expire, so the limiter store grows without bound across all keys ever seen (Memory Leaks in Backend Services).
- Concurrent token consumption where refill and decrement are separate operations — two requests each compute the same refill and each take a token that only one bucket had (Atomic Operations).
- A request arriving exactly at a fixed-window boundary, counted into the old or new window depending on which instance's clock is consulted.
- Sliding-window log trimming racing with insertion, so an entry is counted after being trimmed or dropped before being counted.
- Two instances refilling a shared bucket from their own clocks, double-crediting tokens unless the store's time is authoritative (Backend Races).
- An adversary will find and use the boundary burst. For anything security-relevant — login, password reset, token issuance — a fixed window is not sufficient (Rate Limiting as a Security Control in Security).
- A large token bucket is a large permitted burst by definition, which is exactly what an attacker wants for enumeration. Bucket size for authentication endpoints should be small even where sustained rate is generous.
- Leaky bucket queueing can be turned into a resource-consumption attack: an attacker fills the queue, and legitimate requests are delayed or rejected behind it (Backpressure).
- Response headers reveal the algorithm's state, which is useful to legitimate clients and also tells an attacker precisely how much capacity remains.
- "Fixed window enforces N per minute." It enforces N per aligned minute, which permits up to 2N inside a sixty-second span that straddles a boundary.
- "Token bucket and leaky bucket are the same thing." They are near-opposites in effect: token bucket permits bursts up to the bucket size; leaky bucket eliminates bursts by emitting at a constant rate.
- "Sliding window is always better." It is more accurate and more expensive. Where the burst is harmless, the extra cost buys nothing.
- "The algorithm is the important decision." The dimension and the atomicity matter more. An exact sliding window keyed on a forgeable identity enforces nothing (Rate Limiting).
- "A bigger bucket is more generous." It is also a bigger permitted burst hitting whatever is downstream, which may be the thing you were protecting.
Operating it
- Rejection rate per algorithm and per key, plus how far over the limit rejected callers were. A caller marginally over is a tuning problem; a caller far over is an abuse problem.
- For token buckets, the distribution of bucket fill at request time. Buckets that are always full mean the limit never binds; buckets pinned at zero mean the refill rate is below real demand.
- For fixed windows, request rate plotted against the boundary. A sawtooth aligned to window edges is the burst, visible directly.
- Limiter store operation latency, since every algorithm here is in the path of every request (The Metrics a Backend Must Emit).
- Key cardinality in the limiter store, to catch unbounded key growth before it becomes a memory incident (Cardinality: The Label That Took Down Monitoring in Performance).
- Per-request store round trips dominate at high request rates, which is what pushes large systems toward local approximate counters synchronised periodically, or toward drawing a local allowance in batches from a shared budget.
- Sliding window log memory scales with limit times active keys, and is the first algorithm to become unaffordable.
- Token bucket is the cheapest of the precise algorithms: two numbers per key, updated in place, with no per-request history.
- Hot keys make a single counter a contention point regardless of algorithm, and sharding a key's budget across shards trades exactness for throughput (Hot Keys: When Aggregate Metrics Hide a Saturated Node in Performance).
- At the edge, coarse fixed-window limiting is often the right choice precisely because it is cheap, with precise limiting applied later where identity is known (Load Balancing, From the Backend's Side).
- Precision costs storage and computation. The exact algorithm is the most expensive one, and the cheapest one allows double the limit.
- Token bucket's burst tolerance is a feature for clients and a risk for the downstream that must absorb the burst.
- Leaky bucket smooths output and adds latency, and a client waiting in a queue cannot tell that from a slow service.
- Local approximate limiting is fast and permits overshoot proportional to instance count, which is acceptable for fairness and not for abuse prevention.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALThe four algorithms and their burst characteristics are properties of the counting scheme, independent of language and store.
- DATABASE-SPECIFICWhich algorithms are practical depends on the store's atomic primitives. A single atomic increment is enough for fixed window; token bucket needs several reads and writes to be indivisible, which means a server-side script, a single conditional update carrying the arithmetic, or a compare-and-set retry loop. A store with a sorted-set type makes a sliding window log natural; one without it makes the same algorithm impractical.
- SIMPLIFIEDPresents the algorithms in their textbook form. Production limiters routinely combine them — a token bucket per key layered under a coarse fixed window at the edge — and use approximation to avoid a store round trip on every request.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.
- — Distributed Systems — approximating a global counter across instances, and what accuracy you give up to avoid coordinating on every request.