IntegrationsGENERALDATABASE-SPECIFICSIMPLIFIED

Rate Limit Algorithms

Fixed window, sliding window, token bucket and leaky bucket — what each one allows, what it refuses, and the burst each permits.

What actually happensHow to build it

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.

The question

Which counting algorithm should enforce my limit, and what does each one actually allow through?

The requirement

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.

The obvious build

Count requests in the current minute. When the minute rolls over, reset the counter to zero.

Why it breaks

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.

How it breaks in production
  • 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.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

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.

AlgorithmState per keyBurst it allowsBest forMain cost
Fixed windowOne counter + expiryUp to 2x the limit across a boundaryCheap coarse limits, internal services, edge backstopsThe boundary burst, and a synchronised spike at each reset
Sliding window logA timestamp per requestNone — exactSmall limits where exactness is requiredMemory proportional to limit times active keys
Sliding window counterTwo countersMinimal; slight approximation errorPublic APIs needing accuracy without per-request historyAssumes the previous window was evenly distributed
Token bucketToken count + last refill timeUp to the bucket size, deliberatelyClient-facing APIs with naturally bursty clientsDownstream must absorb a full-bucket burst
Leaky bucketQueue depth + last drain timeNone — output is constant rateProtecting a downstream with a hard instantaneous limitAdds 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.

Fixed window: two windows, one burst
all allowedcount -> 0all allowedthe downstream sees the sumWindow 1: counter 0/100100 requests at the very endBoundary: counter resetsWindow 2: counter 0/100100 requests at the very start200 requests in a fraction of a window
UserLLMAgentToolDataDecisionHumanGuardrail

A token bucket that is correct under concurrency

DATABASE-SPECIFICWritten for a store that executes scripts atomically against a single key. In a relational database the same invariant is one UPDATE that carries the refill arithmetic inline and returns the new token count, serialised by the row lock — durable and transactional, at the cost of lock contention on a hot key. In a store with only conditional writes it becomes a compare-and-set retry loop, which is correct but does more work under contention.

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.

Token bucket as a single atomic store operation
1// Executed server-side in the store, so read-refill-decrement-write
2// cannot interleave with another instance doing the same thing.
3// TIME comes from the store: instances with skewed clocks would
4// 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 absorb
8 local refill = tonumber(ARGV[2]) -- tokens per second = sustained rate
9 local cost = tonumber(ARGV[3]) -- cost-weighted: not every call is 1
10 local now = tonumber(redis.call('TIME')[1])
11
12 local tokens = tonumber(bucket[1]) or capacity
13 local updated = tonumber(bucket[2]) or now
14
15 -- refill for elapsed time, never above capacity
16 tokens = math.min(capacity, tokens + (now - updated) * refill)
17
18 if tokens < cost then
19 -- how long until enough tokens exist: a real Retry-After,
20 -- not a constant we guessed
21 local wait = math.ceil((cost - tokens) / refill)
22 return { 0, tokens, wait }
23 end
24
25 tokens = tokens - cost
26 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-After computed from the actual state rather than a constant (The Rate-Limit Contract in API Design).

What can go wrong

Failure modes
  • 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).
What can race
  • 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).
Security
  • 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.
Misreads
  • "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

How you see it in production
  • 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).
What changes at 10x and 100x
  • 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).
What this costs
  • 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.

Architecturerate-limiting
Domains that do not exist yet
  • Distributed Systems — approximating a global counter across instances, and what accuracy you give up to avoid coordinating on every request.