Rate Limiting: Fixed Window vs Sliding Window
Rate limiting protects a service by deciding whether a caller has spent too much request budget. This page focuses on the Redis mechanics behind one common trap: fixed-window limiters can allow a burst at the boundary between two windows.
TLDR
- What breaks: a fixed-window limiter can allow two full batches back-to-back when one lands just before a clock boundary and the next lands just after it.
- Why it breaks: the counter key changes at the boundary, so the second batch sees a fresh counter even though very little real time has passed.
- What improves it: a sliding window stores request timestamps in a Redis sorted set and counts only requests inside the last N seconds.
- Production warning: sliding windows prevent boundary bursts, but they are not fully smooth. A caller can still spend the whole limit immediately.
Detailed Explanation
A rate limiter needs a definition of "too many requests." The simplest definition is fixed window: allow up to N requests during each clock-aligned interval, such as 8 requests from 11:29:00 to 11:29:10.
The problem is that real users do not care about your clock boundary. If they send 8 requests at 11:29:09.8 and 8 more at 11:29:10.2, the fixed-window limiter sees two different windows. In real time, the service received 16 requests in roughly 400ms.
A sliding window changes the question. Instead of "how many requests since the last boundary?", it asks "how many requests are inside the last N seconds from right now?" That is why the second batch is denied in this lab: the first batch is still only about 400ms old.
Concept Deep Dive
Fixed Window
The fixed-window implementation uses one Redis counter per window. The important line is the window start calculation: winStart = nowSec - (nowSec % windowSec). That value becomes part of the Redis key, such as rl:fixed:1718609340.
INCR is atomic, and the script is atomic too. The bug is not concurrent clients corrupting the counter. The bug is the meaning of the counter: it forgets about the previous window exactly when the clock crosses the boundary.
Sliding Window
The sliding-window implementation uses a Redis sorted set. Each allowed request is stored as a member whose score is the request timestamp in milliseconds. Before checking the count, the script removes old scores with ZREMRANGEBYSCORE.
After cleanup, ZCARD tells us how many requests are still inside the rolling window. If that count is already at the limit, the request is denied. Otherwise the script records the new request with ZADD.
Atomic Lua
Both limiters run through EVAL. That means each check-and-update runs inside Redis without another command interleaving halfway through. Lua solves atomicity. It does not rescue a weak rate-limit model.
Smoothness
Sliding window is stricter than fixed window at boundaries, but it is not a smooth limiter. With a limit of 8 per 10 seconds, a caller can still send 8 requests immediately. A truly smooth limiter needs spacing, usually with GCRA, leaky bucket, or a token bucket configured with a small burst.
Redis Commands In This Topic
EVAL script keys args
The Go driver sends each limiter decision to Redis as one script. That keeps check plus update atomic.
INCR rl:fixed:<start>
Counts requests in one fixed window. The suffix changes when the clock crosses the boundary.
EXPIRE key seconds
Cleans up fixed-window counters. It is not what enforces the limit. The count comparison does that.
ZREMRANGEBYSCORE rl:sliding 0 cutoff
Deletes timestamps that are too old to count. This is what makes the window roll forward continuously.
ZCARD rl:sliding
Counts the remaining timestamps after cleanup. If this count is already at the limit, deny.
ZADD rl:sliding now uid
Records an allowed request. The score is the timestamp. The member is unique so multiple requests in the same millisecond do not overwrite each other.
PEXPIRE rl:sliding ms
Lets Redis delete the sorted set after the user stops sending requests.
Lua Script Breakdown
Fixed-Window Script
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, windowSec * 2)
end
if count > limit then return {0, count, remaining} end
return {1, count, remaining}
winStart = nowSec - (nowSec % windowSec)Rounds time down to a clock-aligned window. This is why the boundary burst exists.
key = KEYS[1] .. ':' .. winStartCreates a separate Redis key for each fixed window. A request after the boundary no longer sees the previous count.
INCRAtomically increments the counter for that one window. This part is correct and safe under concurrency.
EXPIRESets cleanup TTL only when the counter is first created. The lab uses windowSec * 2 so old counters live long enough to inspect briefly.
return {0 or 1, count, remaining}Returns allow/deny, the counter value, and seconds until the next fixed-window reset.
Sliding-Window Script
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
local count = redis.call('ZCARD', key)
if count >= limit then return {0, count} end
redis.call('ZADD', key, now, uid)
redis.call('PEXPIRE', key, windowMs)
return {1, count + 1}
ZREMRANGEBYSCORERemoves timestamps older than now - windowMs. Unlike fixed window, this cutoff changes every millisecond.
ZCARDCounts requests still inside the last N seconds.
count >= limitDenies before adding this request. That avoids temporarily storing a request that should not count.
ZADDRecords the accepted request with a unique member id. The score gives Redis the time ordering.
PEXPIRECleans up the key after activity stops. The limiter would still work without this, but abandoned keys would accumulate.
Lab Details And Output
The runnable lab is labs/redis/ratelimit. It deliberately waits for a clock boundary, sends 8 requests just before it, then sends 8 more just after it.
make break
Runs fixed-window mode. It should fail because both batches are allowed.
make test
Runs sliding-window mode. It should pass because the second batch is denied.
make load
Runs a larger sliding-window scenario with a higher limit.
Fixed Window Output
The evidence is in the result block: phase-1 allowed = 8, phase-2 allowed = 8, and total in ~400ms span = 16. That is why make break exits 1.
make break run. The fixed-window counter resets at the boundary and allows both batches.Sliding Window Output
The sliding-window run uses the same timing, but phase-2 denied = 8. The first batch is still inside the rolling 10-second window, so the second batch cannot spend a fresh budget.
make test run. Phase-1 entries remain inside the rolling window, so phase-2 is denied.Code Pointers
| Code | Why it matters |
|---|---|
fixedWindowScript | Builds a counter key from the window start time. That creates the reset at the boundary. |
slidingWindowScript | Stores request timestamps in a sorted set and removes only entries older than the rolling window. |
runFixed | Waits until 200ms before a clock boundary, sends the first batch, waits 200ms after the boundary, then sends the burst. |
runSliding | Uses the same timing as runFixed, but the first batch is still inside the rolling window when the second batch arrives. |
ratelimit/Makefile | Targets that run fixed-window breakage and sliding-window recovery. |
ratelimit/compose.yaml | Redis container used by the lab. |
ratelimit/README.md | Scenario notes and expected fixed/sliding behavior. |
Production Notes
- Mental trap: Lua makes the update atomic, but it does not make fixed-window semantics fair.
- Fixed window is simple, but it allows boundary bursts.
- Sliding window prevents boundary bursts, but it is not a strict smooth limiter. A client can still spend the full limit immediately and then wait.
- For smooth spacing, use GCRA or a leaky-bucket style limiter with
next_allowed_ator theoretical arrival time. - Use server-side time or consistent app time when multiple app instances apply the same limiter.