Architecture patterns
Distributed limiter on Redis
How many servers share one rate-limit count without races, using an atomic Redis Lua script, one clock, expiring keys and a deliberate failure mode.
On one server, an in-memory token bucket is enough. With 200 app servers behind a load balancer, each request for a client can land on any of them. The count must live in a shared store. Redis is the usual choice, because it is fast and supports atomic operations and server-side scripts.
The big idea
The naive approach reads the state, computes the result, and writes it back. Two servers can interleave those steps. Both read the same value, and both allow the request. The fix is to make the check and the update one step that no other command can interrupt. Redis runs a Lua script atomically, so the script is the standard answer.
Walking through it
Here is the race in a trace. Two servers share a bucket with 1 token left:
Server A: GET tokens → 1 Server B: GET tokens → 1
Server A: tokens ≥ 1, allow Server B: tokens ≥ 1, allow
Server A: SET tokens = 0 Server B: SET tokens = 0Two requests were allowed with one token. Under load, this race lets clients exceed their limit by a multiple.
The script below does the whole token bucket in one atomic step:
-- KEYS[1] = bucket key; ARGV = capacity, refill per second, cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local cost = tonumber(ARGV[3])
local t = redis.call('TIME') -- Redis's clock, not the caller's
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) / 1000 * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 1000) + 1000)
return { allowed, tostring(tokens) }A first call on a new client, with capacity 100 and rate 10, returns:
1) (integer) 1
2) "99"Two details separate a working limiter from a subtly broken one.
Clocks. If each app server sends its own time, a server whose clock runs 2 seconds fast adds 2 seconds of extra tokens. Reading TIME inside the script gives every server the same clock. Since Redis 5, scripts replicate their effects, so calling TIME inside a script is safe. The general rule is covered in clocks and ordering.
Expiry. Without a TTL, every client you have ever seen stays in memory. A bucket that has refilled completely is the same as no key at all, so the script sets a TTL that expires it once it would be full again.
When Redis is down
Decide in advance whether the limiter fails open (allows everything) or fails closed (rejects everything). For abuse and fairness limits, fail open. A limiter outage should not become an API outage. For limits that protect something fragile, such as a partner quota with financial penalties, fail closed or fall back to conservative local limits. Wrap the Redis call in a tight timeout, around 5 to 20 ms, so a slow limiter cannot add seconds to each request.
Common mistakes
- Doing the read-modify-write in application code. Use one script, so no other command runs in between.
- Passing the caller's clock into the script. Each server's clock drifts. Use Redis's
TIME. - Skipping TTLs. Memory grows with every key ever seen.
- Choosing fail-open or fail-closed by default. Decide per limit, and set a short timeout.
Check yourself
-
Two servers each read
tokens = 1and both allow. What is the fix?Answer: run the check and decrement as one Lua script, so Redis runs it with no other command in between.
-
Why does the script call
TIMEinstead of taking a timestamp argument?Answer: every server then shares one clock, so a server with a fast clock cannot add extra tokens.
Key takeaways
- A read-modify-write on Redis races. Use a Lua script, which Redis runs atomically.
- Read the clock inside the script, so the whole fleet uses one clock.
- Set TTLs on bucket keys, so memory tracks active clients.
- Decide fail-open or fail-closed in advance, with a short timeout on the call.
Further learning
Next, see how to avoid a Redis round trip on every request in local plus global limits.
Related
Rate limiting
Why systems cap how much work a client can send, which algorithm counts, where the limit lives and what the client is told. Start here for the rate-limiting map.
Token bucket
A bucket that holds tokens and refills over time, giving you two dials for a sustained rate and a burst size. The most common shape for API limits.
Local plus global limits
How high-volume limiters avoid a Redis call per request by deciding locally and syncing every few hundred milliseconds, and what overshoot that costs, worked through a 1 million requests per second design.
Time, clocks and ordering
Why wall clocks lie across machines, how skew silently loses writes, and how Lamport clocks, vector clocks, hybrid logical clocks and TrueTime order events you can't time directly.