Rate limiting without a distributed lock
This is a template post — replace the body, keep the structure. Every element you're likely to need is demonstrated below, already styled: headings, quotes, code, lists, images, and a horizontal rule.
The instinct when you first need a rate limiter across several application servers is to reach for a lock. Don't. A lock turns a cheap counter into a coordination problem, and coordination is where latency and outages come from.
The shape of the problem
You want to allow N requests per window per key, across processes that don't talk to each other. The three usual answers, in ascending order of fidelity:
- Fixed window — a counter per key per minute. Trivially cheap, but allows a 2× burst at the boundary.
- Sliding window log — exact, and expensive: you store every timestamp.
- Token bucket — a running balance that refills at a fixed rate. Cheap, smooth, and what you probably want.
The best rate limiter is the one whose failure mode you've actually thought about. Fail open, and an incident becomes a stampede. Fail closed, and Redis going down takes your API with it.
A token bucket in Redis
The whole thing fits in one atomic script, which is what removes the need for a lock — Redis executes it single-threaded, so the read-modify-write can't interleave.
-- KEYS[1] = bucket key, ARGV = capacity, refill_rate, now, cost
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or capacity
local ts = tonumber(b[2]) or now
tokens = math.min(capacity, tokens + (now - ts) * rate)
if tokens < cost then return {0, tokens} end
tokens = tokens - cost
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return {1, tokens}
Note the EXPIRE. Without it you accumulate a key per unique caller forever,
and discover the problem months later when memory runs out on a Sunday.
Choosing your failure mode
When Redis is unreachable, you have to pick. My default is a local in-process limiter
as a fallback, sized at global_limit / replica_count. It's wrong under
uneven load balancing, but it's wrong in a bounded way — which is more than fail-open
can claim.
Have a better approach, or found a bug in the script above? Tell me — I'll update the post.