Sliding Window Rate Limiting: Fixing the Redis Race Condition with Lua
A common approach to rate limiting in Spring Boot is checking a Redis counter and then incrementing it. However, under high concurrency…
Sliding Window Rate Limiting: Fixing the Redis Race Condition with Lua

A common approach to rate limiting in Spring Boot is checking a Redis counter and then incrementing it. However, under high concurrency, this multi-trip sequence introduces a Time-of-Check to Time-of-Use (TOCTOU) race condition. Ten concurrent requests can read the same counter value, pass the check simultaneously, and violate your strict limit.
To prevent this, the entire check-and-write phase must be executed as a single, indivisible event.
The Core Algorithm: Redis Sorted Set
To achieve exact enforcement without boundary bursts, we map a Sliding Window Log onto a Redis Sorted Set (ZSET):
- Key:
rate_limit:{clientId} - Score: Epoch milliseconds (the wall-clock arrival timestamp)
- Value: Unique UUID v4 (prevents requests arriving at the exact same millisecond from overwriting each other)
[========================== 60-Second Moving Window ==========================]
| |
| (Evicted via Score) |
| [Req 1: t=10000] ------- [Req 2: t=25000] ------- [Req 3: t=50000] ------- [Req 4: t=75000]
| |
+----------------------------+------------------------------------------------+
|
t=15000 Cutoff
(nowMs - windowMs)
Every time a request arrives, we sliding-evict expired timestamps using ZREMRANGEBYSCORE from 0 to (nowMs - windowMs). We then check the remaining elements with ZCARD. If the count is below the limit, the request is allowed and committed via ZADD.
The Solution: Atomic Lua Execution
Because Redis operates on a single-threaded event loop, encapsulating this logic inside a Lua script ensures absolute atomicity. No other operation can interleave or observe a half-finished state.
local key = KEYS[1]
local now = tonumber(ARGV[1])
local winStart = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local uuid = ARGV[4]
local ttl = tonumber(ARGV[5])
-- Step 1: Evict expired entries
redis.call('ZREMRANGEBYSCORE', key, 0, winStart)
-- Step 2: Count active entries in window
local count = redis.call('ZCARD', key)
if count < limit then
-- Step 3a: Allow — record this request
redis.call('ZADD', key, now, uuid)
redis.call('EXPIRE', key, ttl)
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local oldestMs = oldest[2] and tonumber(oldest[2]) or -1
return {1, count + 1, oldestMs}
else
-- Step 3b: Reject — peek oldest to compute Retry-After
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
local oldestMs = oldest[2] and tonumber(oldest[2]) or -1
return {0, count, oldestMs}
end
Telemetry Payoff: By returning the raw oldestEntryMs timestamp, the backend can calculate a precise millisecond-perfect Retry-After header for rejected requests: (oldestMs + windowMs) - nowMs.
Visualizing the Moving State with a Live UI
While the backend guarantees correctness, externalizing this internal state via a reactive dashboard brings the system design to life. The stack runs via a 3-container Docker Compose pipeline (redis $\rightarrow$ spring-boot $\rightarrow$ node-ui).
redis:7.2-alpine ← rate-limit-redis
spring-boot app ← rate-limit-service (Java 21, Spring Boot 3.3)
node:20-alpine + express ← rate-limit-demo-ui (Port 3000)
The Visual Debugger

Demo UI
The **web UI** exposes the exact state of the distributed algorithm in real time:
- Live Precision Clock: Streams current Unix epoch time so you can watch timestamps scroll, matching the score structure saved inside Redis.
- Animated SVG Countdown Ring: Uses the backend’s
oldestEntryMspayload to visually countdown exactly when a blocked slot will slide out of the moving window horizon. - The Boundary Test Script: Resets the ZSET, fires exactly
limitsuccessful requests (200 OK), and attempts a finallimit + 1execution, which is instantly blocked (429 Too Many Requests). This serves as a real-time proof that the moving boundary is flawless.
Above UI and backend service code is available ***here*** for you to play.
For security-critical barriers like login endpoints and payment gateways, combining a Sliding Window Log with Lua atomicity ensures that your application limits remain uncompromised.
메타데이터
- post_id
- 04f7523dd3ce
- slug
- sliding-window-rate-limiting-fixing-the-redis-race-condition-with-lua-04f7523dd3ce
- url
- https://medium.com/@meaningfulblogger9/sliding-window-rate-limiting-fixing-the-redis-race-condition-with-lua-04f7523dd3ce
- canonical_url
- https://medium.com/@meaningfulblogger9/sliding-window-rate-limiting-fixing-the-redis-race-condition-with-lua-04f7523dd3ce
- author_url
- https://medium.com/@meaningfulblogger9
- status
- ok
- fetched_at
- 2026-07-21 07:04:46