← Back to list

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…

Kiran · 2026-07-14 09:50 · 36 claps · 2.7 min read
#java #redis #rate-limiting #design-systems #spring-boot
Open on Medium ↗
Wiki topics: PRD · Product Design SOC · Sociology & Politics

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

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 oldestEntryMs payload 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 limit successful requests (200 OK), and attempts a final limit + 1 execution, 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