Redis atomic rate limiting is surprisingly difficult to get right, and most naive implementations leak requests under high load.

Let’s watch a real-time rate limiter in action. Here’s a Lua script that implements a sliding window rate limiter. It takes three arguments: the key for the rate limiter (e.g., user:123:requests), the maximum number of requests allowed within the window, and the window duration in seconds.

local key = KEYS[1]
local max_requests = tonumber(ARGV[1])
local window_seconds = tonumber(ARGV[2])

local current_time = redis.call('TIME')[1]
local window_start = current_time - window_seconds

-- Remove timestamps older than the window
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)

-- Add the current request's timestamp
redis.call('ZADD', key, current_time, current_time .. ':' .. redis.call('INCR', key .. ':id')[1])

-- Set expiration for the sorted set key to prevent memory leaks
redis.call('EXPIRE', key, window_seconds + 5) -- Add a small buffer

-- Count the number of requests in the window
local count = redis.call('ZCARD', key)

if count > max_requests then
    return 0 -- Rate limited
else
    return 1 -- Allowed
end

To use this, you’d call it from your application like so:

redis-cli -h your_redis_host -p your_redis_port EVAL "local key = KEYS[1] ... end" 1 user:123:requests 100 60

This command executes the Lua script. 1 is the number of KEYS arguments, followed by the key itself (user:123:requests), the max_requests (100), and the window_seconds (60). If the script returns 1, the request is allowed; if it returns 0, it’s rate-limited.

The core problem this solves is preventing a user or service from exceeding a certain number of requests within a defined time frame. Traditional methods, like checking a counter and resetting it periodically, suffer from race conditions. If two requests arrive simultaneously when the counter is at its limit, both might be processed before the counter is updated.

This Lua script uses a sorted set (ZSET) in Redis to keep track of request timestamps. Each element in the sorted set is a timestamp, and the score is also the timestamp. We also append a unique ID to the member to ensure uniqueness, as Redis sorted sets require unique members.

  1. redis.call('TIME')[1]: Gets the current Unix timestamp.
  2. window_start = current_time - window_seconds: Calculates the timestamp marking the beginning of our sliding window.
  3. redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start): This is crucial. It efficiently removes all request timestamps from the sorted set that fall outside the current sliding window (i.e., are older than window_start). This keeps the sorted set lean and only contains relevant data.
  4. redis.call('ZADD', key, current_time, current_time .. ':' .. redis.call('INCR', key .. ':id')[1]): This adds the current request’s timestamp to the sorted set. We use current_time as both the score and part of the member. Appending a unique ID from an atomic INCR counter (key .. ':id') ensures that even if multiple requests arrive in the exact same second, they are stored as distinct entries in the sorted set.
  5. redis.call('EXPIRE', key, window_seconds + 5): This sets an expiration on the sorted set key. This is a safeguard to prevent the key from accumulating indefinitely if no more requests are made, ensuring memory is reclaimed. We add a small buffer to the expiration to account for potential clock skew or slight delays.
  6. redis.call('ZCARD', key): This returns the number of elements (requests) currently in the sorted set, which represents the count of requests within the sliding window.
  7. if count > max_requests then return 0 else return 1 end: The final decision. If the count exceeds the max_requests limit, we return 0 (rate limited); otherwise, we return 1 (allowed).

The atomicity of Lua scripts in Redis is what makes this work reliably. Redis executes the entire script as a single, indivisible operation. This means that no other Redis command can interrupt the script between checking the count, adding a new request, and returning the decision. This prevents the race conditions that plague simpler implementations.

What most developers miss is how the ZADD member needs to be unique, even if timestamps are identical. Simply using current_time as the member would cause subsequent requests within the same second to overwrite previous ones, effectively making the rate limiter less strict than intended. The INCR counter appended to the timestamp provides that necessary uniqueness.

The next hurdle is handling distributed rate limiting across multiple Redis instances or nodes, which involves more complex synchronization strategies.

Want structured learning?

Take the full Rate-limiting course →