Redis Atomic Counters Need Atomic Expiration Too
Guepard Team
14 novembre 2026
INCR is atomic, but an INCR followed by EXPIRE is two operations. If a client fails between them, a newly created counter may never receive its intended expiry. For rate limiting or short-lived usage accounting, that gap can change behavior long after the failed request disappears.
Group the decision and expiration into one server-side operation when the business rule requires them to succeed together. Keep the script small and bounded.
The concrete problem
The example implements a fixed-window request counter for one key. It is not a sliding-window limiter and does not claim perfect fairness at window boundaries. Redis Lua scripting executes atomically relative to other commands, but long scripts block other work.
Figure: Request counter increment → First increment sets expiry → Atomic result with remaining TTL.
A worked example
-- KEYS[1]: rate-limit key; ARGV[1]: window length in seconds
local seconds = tonumber(ARGV[1])
if not seconds or seconds < 1 or seconds ~= math.floor(seconds) then
return redis.error_reply('Window must be a positive integer')
end
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], seconds)
end
return {n, redis.call('TTL', KEYS[1])}
-- Invoke through EVAL/EVALSHA with explicit keys.
-- The application compares n with the allowed limit.
Why the behavior matters
Atomic execution prevents interleaving; it does not roll back earlier writes if a later script statement fails. The script validates the window before its first write. Keep that distinction explicit when extending it.
The script sets expiry only when the counter is first created by INCR. Subsequent requests increment within the existing window instead of extending it indefinitely. A preexisting key of the wrong type should fail rather than be silently overwritten; define an isolated key namespace and validate inputs before invocation.
In Redis Cluster, all keys touched by a multi-key script must satisfy the relevant slot constraints. This script uses one key, avoiding that particular complication. Still, deployment behavior, script caching and failover need handling in the client. Do not assume EVALSHA can never require a fallback after a restart.
Failure modes and design tradeoffs
A fixed window can permit bursts near the boundary: a client may consume one allowance just before expiry and another immediately after. If that violates requirements, use a sliding-window or token-bucket design and account for its state and clock assumptions.
The application also needs an explicit outage policy. Failing open may protect availability but weaken abuse control; failing closed can deny legitimate requests. The choice depends on the endpoint and should not be hidden inside a generic Redis retry wrapper.
What to verify before adoption
Test first request, repeated requests, expiry, wrong-type keys and concurrent clients. Simulate the original two-command failure to demonstrate the immortal-key bug, then verify the script avoids that specific gap. Check counts and TTLs together. Do not interpret the script's atomicity as a guarantee about unrelated database or HTTP side effects.
Primary reference
- Redis documentation. Verify the exact deployed version and driver behavior before using the example in a production runbook.
Guepard Team
Guepard Engineering