jonnify·redis /redis-patterns/flow-control/rate-limiting/fixed-and-sliding-windows

R6.01.1 · Rate limiting — dive

Fixed & sliding windows — a counter and a clock.

The simplest rate limiter is a counter and a clock: count what is admitted in the current slice of time, refuse once the count reaches the cap, and let the count reset when the slice rolls. That is the fixed window, and it is the exact shape EchoMQ ships — a counter spent down against a configured max, where the window is the key's own TTL.

Grounding: echo/apps/echo_mq/lib/echo_mq/metrics.ex · EchoMQ.Metrics.get_rate_limit_ttl/3 (the @rate_ttl Lua, the fixed-window counter read against emq:{q}:limiter and emq:{q}:meta) · meter.ex · EchoMQ.Meter.rate_limit_hit/2, the [:emq, :rate_limit, :hit] receipt.

One burst, two windows, the seam between them. Select a request to read whether it was admitted and how the counter stood. Six requests fall across the boundary of two adjacent fixed windows — watch what the seam lets through.
W1 · max 3 W2 · max 3 window rolls → counter resets R1 R2 R3 R4 R5 4 admits across the seam
readout · Select a request. W1 admits its 3; the counter resets at the seam; W2 admits 3 more. The cluster at the boundary — 2 at the end of W1, 2 at the start of W2 — sends 4 requests in a short span, more than one window's cap of 3, while neither window's own counter is ever exceeded.
§1

The fixed window

metrics.ex · the @rate_ttl read

Pick a window — one second, one minute — and a cap max. Keep one counter per window. On each admitted request, increment the counter; on the first increment, set the counter to expire when the window ends; refuse any request that arrives while the counter has already reached max. The reset is not a scheduled job — it is the key expiring. When the key is gone, the next request recreates it at one, and a fresh window has begun.

Two Valkey commands carry the whole algorithm. INCR creates the key at one if it does not exist and returns the new value atomically — the count and the create are a single operation, so two concurrent admits can never both read zero and both write one. PEXPIRE arms the window: set it once, on the first hit, to the window length in milliseconds, and never reset it again — resetting it on every hit would slide the deadline forward forever and the window would never roll. The remaining lifetime, read with PTTL, is precisely how long until the budget resets — the back-off a request over budget must wait.

EchoMQ ships this as a read. EchoMQ.Metrics.get_rate_limit_ttl/3 declares two keys — the limiter counter emq:{q}:limiter and the queue's meta hash emq:{q}:meta — and answers the remaining window in milliseconds, or 0 when the queue is not over budget.

EchoMQ.Metrics · echo/apps/echo_mq · the @rate_ttl Lua: the fixed-window counter, read sideverbatim
local max = tonumber(ARGV[1])
if max == 0 then
  max = tonumber(redis.call('HGET', KEYS[2], 'max') or '0')   ← the cap, from emq:{q}:meta
end
if max > 0 and max <= tonumber(redis.call('GET', KEYS[1]) or '0') then   ← counter reached max?
  local pttl = redis.call('PTTL', KEYS[1])   ← the window: the key's own TTL
  if pttl > 0 then return pttl end          ← ms until reset = the back-off
end
return 0                                     ← 0 = admit, not over budget

The script reads the cap (HGET emq:{q}:meta max when the caller passes zero), compares the live counter against it (GET emq:{q}:limiter), and — only when the counter has reached max and the window still has time left — returns the remaining PTTL. A 0 means admit; any positive number is the milliseconds to wait. Every key the script touches is declared in KEYS[], so the {q} hashtag pins both keys to one cluster slot and the read is atomic and slot-legal. When a limit is hit, EchoMQ.Meter.rate_limit_hit/2 emits [:emq, :rate_limit, :hit] with the delay — a telemetry receipt, no cost when :telemetry is not loaded.

Notes on ValkeyPEXPIRE sets the window once, on the first hit, so the counter's own time-to-live is the window — no separate timer, no scheduled reset. INCR is the atomic create-and-count beneath it: the new value is returned in one operation, so concurrent admits cannot both miss the cap — valkey.io/commands/incr.

A fixed-window limiter is one counter and one TTL: INCR to count, PEXPIRE once to arm the window, refuse at max, and the key's expiry rolls the window. EchoMQ reads that exact shape with get_rate_limit_ttl/3 — the remaining PTTL is the over-budget back-off.

§2

The boundary burst

the flaw at the seam

The fixed window has one flaw, and it lives at the seam. The counter resets the instant the window rolls, with no memory of how the previous window was spent. A client that times its requests to the boundary can spend the whole budget at the very end of one window and the whole budget again at the very start of the next — max requests in the last moment of window one, max more in the first moment of window two. Across the short span straddling the seam, the client sent up to 2 × max requests, while every window's own counter stayed within its limit.

The cause is that the fixed window measures against a fixed grid of slices, not against a span that moves with the request. A burst that lands inside one slice is bounded; a burst that lands across a slice boundary is bounded per slice but not across the seam, because the two slices are counted independently. The window the limiter enforces and the window the client experiences are not the same window.

Fixed vs sliding, over the same burst. Select an algorithm to read how it scores the boundary cluster and what it costs to run. The fixed window counts per slice; the sliding ones measure a span that ends now.
over the same boundary burst fixed window O(1) · bursts at seam sliding counter O(1) · smooth, approx sliding log O(n) · exact
readout · Select an algorithm. The fixed window admits up to 2× max across the seam for one counter and one TTL. The sliding counter weights the previous window and smooths the seam, still O(1). The sliding log counts the exact requests in the rolling span — exact, but O(n) memory.

The fixed window is bounded per slice but not across the seam: a burst timed to the boundary sends up to twice the cap. For a coarse throughput budget that is acceptable; when the cap is the real protection, the window has to move with the request.

§3

The sliding refinement

the counter pair · the sorted log

A sliding window measures against a span that ends now and reaches back exactly one window, so there is no grid and no seam to exploit. Two refinements reach it, and they trade memory against exactness.

The sliding-window counter keeps the fixed window's cheap shape and corrects the seam by arithmetic. Keep two counters — the current window and the previous one — and weight the previous window's count by the fraction of it still inside the rolling span. If the rolling window is one minute and fifteen seconds have elapsed into the current window, three-quarters of the previous window is still in view, so the estimate is the current count plus three-quarters of the previous count. It is an approximation — it assumes the previous window's requests were spread evenly — but it is bounded, smooth across the seam, and still costs only two integers per key.

The sliding log is exact. Keep a sorted set of request timestamps — the score is the time, the member is a unique request id. On each request, trim everything older than the span with ZREMRANGEBYSCORE (remove every member whose score is below now − window), then ZCARD is the live count of requests still inside the window; admit if it is below max and ZADD the new timestamp. There is no seam because there are no slices — only a moving span and the exact set of requests inside it. The cost is the trade: the set holds one member per request in the window, so its memory is O(n) in the rate, where the counter is O(1).

AlgorithmStateThe seamThe trade
Fixed windowINCR + PEXPIRE, one counterburstscheapest; up to 2× max at the boundary
Sliding countertwo counters, weightedsmoothO(1); an even-spread approximation
Sliding loga sorted set of timestampsexactO(n) memory in the rate; no slices

The choice is the usual one. The fixed window is cheapest and bursts at edges; the sliding-window counter is nearly as cheap and smooth; the sliding log is exact and the most expensive. EchoMQ's limiter is the fixed window — one counter, one TTL — because a queue's budget is a coarse throughput cap where a boundary burst is tolerable, and the cheapest limiter that holds the budget is the right one. A surface that needs an exact rolling count reaches for the log; one that needs smoothness without the memory reaches for the counter pair.

the pattern

A fixed window counts admits per slice of time and refuses at the cap, where the slice is the counter's own TTL. It is the cheapest limiter and it leaks at the seam — up to twice the cap across a window boundary.

its EchoMQ application

The emq:{q}:limiter counter is spent down to max (from emq:{q}:meta) over its own PTTL window. EchoMQ.Metrics.get_rate_limit_ttl/3 reads the remaining window — the over-budget back-off — and [:emq, :rate_limit, :hit] is the receipt.

Notes on ValkeyThe sliding log is a sorted set: ZREMRANGEBYSCORE key 0 (now-window) drops the timestamps that have aged out, then ZCARD is the exact count still inside the rolling span — the seam disappears because there are no slices to count independently — valkey.io/commands/zremrangebyscore.

§4

The pattern, applied

echo/apps/codemojex

In EchoMQ the fixed-window algorithm is the emq:{q}:limiter counter spent down to max — the cap read from emq:{q}:meta — over its own PTTL window. The window is not a timer the bus runs; it is the key's TTL, set once on the first hit and left to expire. Incrementing the counter on each admitted job and deferring an over-budget job until the window resets is the enforcement loop the queue's scaling layer wires at the dequeue point.

A consumer makes the budget concrete. In codemojex (echo/apps/codemojex) — a Telegram emoji-guessing game on the same stack — a per-queue emq:{q}:limiter counter caps the work one room or one user can push at the engine, so a flood of guesses from one chat cannot starve the others. Because the counter lives in Valkey, not in any worker, the budget is one shared figure across every bot worker — the subject of the global-not-local dive.

§5

References

sources · related

Sources

Related in this course