Skip to content

Rate limiting

intermediateCommon atAnthropicStripeOpenAICloudflareGoogleRead firstSliding windowQueues and dequesHash mapsPractise in the Sandbox →

A rate limiter answers one question per request: has this client used up its allowance for the recent past? The algorithms differ in what they remember. A sliding log stores every accepted timestamp and is exact but costs O(limit) memory per client. A fixed window stores one counter and is cheap but lets through up to 2× the limit across a window boundary. A token bucket stores two numbers, allows a controlled burst, and refills lazily in O(1).

Think of a nightclub with a fire-code capacity. The bouncer can enforce it in several ways:

  • Fixed window: “100 people per hour, counter resets on the hour.” Simple, but 100 people can arrive at 7:59 and another 100 at 8:00.
  • Sliding log: the bouncer writes down the time of every entry and counts the entries in the last 60 minutes. Exact, but the notebook grows with the limit.
  • Sliding window counter: keep only this hour’s count and last hour’s count, and estimate the last 60 minutes by weighting last hour by how much of it still overlaps.
  • Token bucket: the bouncer has a jar of wristbands. One wristband per entry. A new wristband drops into the jar every 36 seconds, up to a jar of 100. Quiet periods let wristbands pile up, so a group can enter at once, but the long-run rate is fixed.
  • Leaky bucket: people queue at the door and are let in at a steady one every 36 seconds. If the queue is full, newcomers are turned away. Output is perfectly smooth; latency is the price.

Every one of these is a trade between accuracy, memory per client, burst tolerance and smoothness of output.

All five limiters get exactly the same traffic. Run the clock and watch the default pattern: four requests just before the boundary at t = 10 and four just after. The fixed window accepts all eight inside a 10-tick span, so its worst burst goes red. Try the other patterns, change L and W, click the top row to add requests, or press Fire now while the clock runs.

Same traffic, five limiters
simulate
0102030405060IncomingFixed windowSliding logSliding counterToken bucketLeaky bucket
algorithmstate at t = 0okrejectedworst 10-tick burst
Fixed windowwindow [0, 10): count 0/4000
Sliding loglog []: 0/4000
Sliding counter0×1.00 + 0 ≈ 0.00 of 4000
Token buckettokens 4.00 / 4, +0.40 per tick000
Leaky bucketqueue 0/4, drains 1 per 2.5 ticks000
Press Run (or step). Click the top row to add requests at any tick, or press "Fire now" while the clock runs.
request sentaccepted / sent onqueued (delayed)rejectedcurrent window
t = 0

Things to look for:

  • Sliding log never exceeds L in any span of W ticks. That is what “exact” means.
  • Sliding counter usually agrees with the log, but it is an estimate. It assumes the previous window’s requests were spread evenly.
  • Token bucket starts full, so a client that was idle can burst L requests at once, then is held to L/W per tick. Its worst burst can exceed L: a full bucket plus what refills during the window. That’s its contract, not a bug.
  • Leaky bucket rejects little but delays a lot: the yellow lines are time spent queued.
The problem says… Why this fits
“at most N requests per client in any rolling T seconds” A precise rolling-window rule: a sliding log, or a counter if approximation is allowed.
“allow short bursts but cap the average rate” Token bucket: capacity is the burst, refill rate is the average.
“protect a downstream service that can only take X per second” Leaky bucket / queue: smooths output to a steady rate.
“millions of clients, memory is tight” Fixed or sliding window counter: O(1) numbers per client.
“different tiers get different limits” Store the limit (or rate and capacity) per client next to its state.
“return how long the client should wait” You need the arithmetic for when capacity frees up (Retry-After).
Algorithm State per client Decision for a request at now Accuracy
Fixed window window id, count reset count if now is in a new window; allow if count < limit up to 2× limit at boundaries
Sliding log timestamps of accepted requests drop timestamps older than now − window; allow if fewer than limit remain exact
Sliding window counter previous count, current count, window id estimate = prev × overlap + curr; allow if estimate + 1 ≤ limit approximate, usually close
Token bucket tokens, last refill time refill by elapsed × rate (capped); allow if tokens ≥ 1, then spend one exact for its own rule (burst + rate)
Leaky bucket (queue) queue, last drain time drain at a constant rate; enqueue if not full, else reject smooth output, adds delay

The key trick: you never run a background timer to add tokens. You store the token count and the time you last updated it, and when a request arrives you credit the whole gap at once.

  1. Compute elapsed = now − last. Clamp at 0 so a clock that steps backwards can’t remove tokens.
  2. tokens = min(capacity, tokens + elapsed × rate), then set last = now. Capping is what makes idle time worth at most one full burst.
  3. If tokens ≥ cost, subtract cost and allow. Otherwise reject, and don’t charge for it.
  4. If you reject, the wait until a token exists is (cost − tokens) / rate. That’s the Retry-After value.
token bucket arithmetic.py
"""Token-bucket arithmetic: lazy refill and Retry-After. Pure functions, no clock reads."""
def refill(tokens: float, last: float, now: float, rate: float, capacity: float) -> float:
"""Tokens in the bucket at `now`, given `tokens` at time `last` (seconds)."""
elapsed = max(0.0, now - last) # 1. a clock that steps back must not drain the bucket
return min(capacity, tokens + elapsed * rate) # 2. credit the whole gap at once, capped at capacity
def take(tokens: float, cost: float = 1.0) -> tuple[bool, float]:
"""3. Spend `cost` tokens if there are enough. Returns (allowed, tokens_left)."""
if tokens >= cost:
return True, tokens - cost
return False, tokens # a rejected request costs nothing
def retry_after(tokens: float, rate: float, cost: float = 1.0) -> float:
"""4. Seconds until `cost` tokens will be available (what a 429 Retry-After header reports)."""
return 0.0 if tokens >= cost else (cost - tokens) / rate
if __name__ == "__main__":
cap, rate = 5.0, 2.0 # burst of 5, sustained 2 requests per second
t = refill(0.0, last=0.0, now=1.5, rate=rate, capacity=cap)
assert t == 3.0 # 1.5 s x 2 tokens/s
assert refill(t, last=1.5, now=100.0, rate=rate, capacity=cap) == cap # idle time is capped
assert refill(t, last=1.5, now=1.0, rate=rate, capacity=cap) == t # clock went backwards
ok, left = take(t)
assert ok and left == 2.0
assert take(0.5) == (False, 0.5)
assert retry_after(0.5, rate) == 0.25
print("ok")

Sliding window counter: the weighted estimate

Section titled “Sliding window counter: the weighted estimate”

Cloudflare popularised this: keep the count for the current fixed window and the previous one. If you are 15 s into a 60 s window, the last 60 s covers 45 s of the previous window, so weight its count by 45/60.

  1. weight = (window − elapsed) / window is how much of the previous fixed window the rolling window still covers.
  2. Estimate prev × weight + curr, assuming the previous window’s requests were evenly spread.
  3. Count the incoming request: allow if estimate + 1 ≤ limit.
window_estimate.py
"""Sliding-window counter: estimate the rolling count from two fixed-window counters."""
def window_estimate(prev_count: int, curr_count: int, elapsed: float, window: float) -> float:
"""Approximate requests in the last `window` seconds.
`elapsed` is how far we are into the current fixed window (0 <= elapsed < window).
"""
if not 0 <= elapsed < window:
raise ValueError("elapsed must be inside the current window")
# 1. the rolling window still overlaps this fraction of the previous fixed window
weight = (window - elapsed) / window
# 2. assume the previous window's requests were spread evenly across it
return prev_count * weight + curr_count
def would_allow(prev_count: int, curr_count: int, elapsed: float, window: float, limit: int) -> bool:
# 3. count the incoming request too: allow only if the estimate stays within the limit
return window_estimate(prev_count, curr_count, elapsed, window) + 1 <= limit
if __name__ == "__main__":
# 42 requests last minute, 18 so far this minute, 15 s in: 42 * 45/60 + 18
assert window_estimate(42, 18, 15, 60) == 49.5
assert not would_allow(42, 18, 15, 60, limit=50) # 50.5 would exceed 50
assert would_allow(42, 18, 30, 60, limit=50) # 21 + 18 + 1 = 40
assert window_estimate(10, 0, 0, 60) == 10 # at the boundary the old window counts fully
print("ok")
Algorithm Time per request Memory per client Burst at a boundary
Fixed window O(1) O(1): 2 numbers up to 2 × limit
Sliding log O(1) amortized (each timestamp evicted once), O(log n) if you binary-search O(limit) timestamps ≤ limit
Sliding window counter O(1) O(1): 3 numbers close to limit; error depends on how uneven traffic was
Token bucket O(1) O(1): 2 numbers capacity at once; up to capacity + rate × window in any window
Leaky bucket (queue) O(1) O(queue size) output never bursts

Why the sliding log is amortized O(1): each accepted timestamp is appended once and popped from the front once, the same argument as a sliding window. A single call can pop many, but the total over all calls is bounded by the number of accepted requests.

The memory column matters most at scale. With 10 million clients and a limit of 1,000 per hour, a sliding log could hold up to 10 billion timestamps. Two counters per client is 20 million numbers.

Idle clients are the other memory cost. A client that sent one request and left still has state. Options: evict lazily when you next touch that key, sweep periodically, or keep clients in an order you can expire from the front (by last activity). Each option trades CPU per call for memory, and you should be able to say which you chose and why.

  • Per-key limits and tiers. The limit becomes data stored per client (or per API key, IP, route). Decide what happens to existing state when the limit changes: do past requests still count against the new limit?
  • Weighted cost. An expensive endpoint costs 5 tokens, a cheap one 1. Token buckets handle this naturally (cost parameter); windows need a sum instead of a count.
  • GCRA (generic cell rate algorithm). A token bucket stored as a single timestamp, the “theoretical arrival time”. One number per client, exact, popular in Redis-based limiters.
  • Distributed limiting. Many servers, one limit. Centralise counters (for example, in Redis with atomic increments or a script), or give each node a share of the budget and accept some slop. Race conditions between “read count” and “write count” are the classic bug; see concurrency.
  • Concurrency limits. “At most 10 in flight”, not “10 per second”. That’s a semaphore, not a rate limiter, and Stripe runs both.
  • Shaping vs policing. A leaky bucket shapes (delays traffic to fit). The others police (reject what doesn’t fit).
  • Off-by-one at the window edge. Is a request exactly window ms old still counted? Write it as an inequality (now − t < window) and test the boundary tick.
  • Counting rejected requests. If rejections consume allowance, a client hammering you never recovers. Usually only accepted requests count; confirm.
  • Floating-point refill drift. 0.1 + 0.2 style errors can make tokens end at 0.9999 and wrongly reject. Store integer milli-tokens, or compute from integer timestamps.
  • Reading the clock inside the limiter. It makes tests flaky. Pass now in.
  • Unbounded per-client state. Every client that ever called you stays in the map unless you reclaim it.
  • Check-then-act races in a concurrent or distributed limiter: two requests both read “4 of 5 used” and both get in.
  • Ask: rolling window or fixed window? Exact or approximate allowed? Burst allowed? Do rejected requests count? Is time passed in, and can it go backwards? How many clients, and do they go idle?
  • Say the trade-off out loud: “A log is exact but O(limit) memory per client; a token bucket is O(1) and allows a burst of capacity. Which contract do we want?”
  • State your eviction plan for idle clients before you’re asked.
  • Expect follow-ups: per-client limits, weighted costs, a distributed version with several servers, returning Retry-After, and “what happens at 100× the number of clients?”
Check yourself
pick one
A fixed-window limiter allows 100 requests per minute, windows starting on the minute. What is the most requests a client can get through in any 60-second span?
Check yourself
pick one
A token bucket has capacity 10 and refills 1 token per second. The client was idle for 5 minutes. How many requests can it make in the next second?
Check yourself
pick one
You must enforce 'at most 1,000 requests per client per hour' for 50 million clients, and a 1-2% overshoot is acceptable. Which is the best fit?

These are classic exercises, not gym questions:

  • Implement a token bucket whose cost varies per call, and add a method that returns the exact time the next call of a given cost would succeed.
  • Implement GCRA with a single stored timestamp and show it makes the same decisions as a token bucket on a random request trace.
  • Write a property test: on random traces, a sliding log never allows more than limit in any window, and a fixed window never allows more than 2 × limit.
  • Build a leaky-bucket shaper that returns the time each accepted request will be sent, and measure the worst-case delay for a burst.