Redis/Rate limiting

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

Quick brief

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.

INCR
boundary
ZREMRANGEBYSCORE
ZCARD

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] .. ':' .. winStart

Creates a separate Redis key for each fixed window. A request after the boundary no longer sees the previous count.

INCR

Atomically increments the counter for that one window. This part is correct and safe under concurrency.

EXPIRE

Sets 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}
ZREMRANGEBYSCORE

Removes timestamps older than now - windowMs. Unlike fixed window, this cutoff changes every millisecond.

ZCARD

Counts requests still inside the last N seconds.

count >= limit

Denies before adding this request. That avoids temporarily storing a request that should not count.

ZADD

Records the accepted request with a unique member id. The score gives Redis the time ordering.

PEXPIRE

Cleans 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.

Terminal screenshot of make break showing a fixed-window boundary burst allowing 16 requests with limit 8.
Captured from a real 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.

Terminal screenshot of make test showing sliding-window rate limiting denying the second burst.
Captured from a real make test run. Phase-1 entries remain inside the rolling window, so phase-2 is denied.

Code Pointers

CodeWhy it matters
fixedWindowScriptBuilds a counter key from the window start time. That creates the reset at the boundary.
slidingWindowScriptStores request timestamps in a sorted set and removes only entries older than the rolling window.
runFixedWaits until 200ms before a clock boundary, sends the first batch, waits 200ms after the boundary, then sends the burst.
runSlidingUses the same timing as runFixed, but the first batch is still inside the rolling window when the second batch arrives.
ratelimit/MakefileTargets that run fixed-window breakage and sliding-window recovery.
ratelimit/compose.yamlRedis container used by the lab.
ratelimit/README.mdScenario notes and expected fixed/sliding behavior.

Production Notes

Cache Locks Streams Pub/Sub