Redis Sliding Window Rate Limit in Practice
How to implement per-user call limits with Sliding Window and reduce false positives and misses

Introduction
The goal of rate limits is to “maintain a normal user experience” rather than “block.” The fixed window method is easy to implement, but allows bursts at boundary points, causing false positives and omissions. In services with urgent traffic, this small difference can lead to failure.
This article covers patterns for reliably applying Redis ZSET-based Sliding Window in practice. In particular, we look at atomicity, memory management, and observation metrics together in a multi-instance API environment.

Problem definition
Common problems in the field include:
- The request count is only processed as
INCR + EXPIRE, so the allowable amount jumps at the window border. - Accuracy decreases when the number of API instances increases due to application-level locking.
- There is a limit exceeded response, but it is not possible to track "which key was blocked and why".
- Memory usage is not predicted because there are no Redis key cleanup rules.
The key is to simultaneously secure atomicity and operational visibility.
Key concepts
| element | Recommended Choice | Reason |
|---|---|---|
| data structure | ZSET (timestamp score) | Simple section deletion and count calculation |
| How it runs | Lua script | Process check + insert atomically |
| key design | rl:{scope}:{id} | Easy operation of multiple policies in parallel |
| response header | X-RateLimit-* | Client Retry Strategy Association |
In practice, a policy is not completed with just one policy. It is safe to separate the user, ip, token policies and apply the strictest result.
Code example 1: Sliding Window Lua script
-- KEYS[1]: rate-limit key
-- ARGV[1]: now (ms)
-- ARGV[2]: window (ms)
-- ARGV[3]: limit
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local current = redis.call("ZCARD", key)
if current >= limit then
local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
local retryAfter = 0
if oldest[2] ~= nil then
retryAfter = window - (now - tonumber(oldest[2]))
end
return {0, current, retryAfter}
end
redis.call("ZADD", key, now, tostring(now))
redis.call("PEXPIRE", key, window)
return {1, current + 1, 0}
Code example 2: Node.js middleware wrapper
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
const windowMs = 60_000;
const limit = 120;
export async function applyUserRateLimit(userId: string) {
const key = `rl:user:${userId}`;
const now = Date.now();
const [allowed, current, retryAfter] = (await redis.evalsha(
process.env.RL_SHA!,
1,
key,
now,
windowMs,
limit,
)) as [number, number, number];
return {
allowed: allowed === 1,
remaining: Math.max(0, limit - current),
retryAfterMs: retryAfter,
};
}
Architecture flow
Tradeoffs
- Sliding Window has high accuracy, but requires more calculations than Fixed Window.
- Using Lua improves accuracy, but the operations team must have a script version control system.
- The more detailed the policy, the better the user protection, but the number of debugging points increases.
Cleanup
Rate limit is both a security feature and a UX feature. When introducing Redis Sliding Window, you should not only look at algorithm accuracy, but also design header standardization and indicator collection to be effective in actual operation.
Image source
- Cover: source link
- License: CC BY-SA 3.0 / Author: Esquilo
- Note: After downloading the free license image from Wikimedia Commons, it was optimized to JPG at 1600px.