← All posts
Tech 12 Aug 2026 7 min read

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:

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.