Distributed Locks
Verified on Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The# cli/...transcript is one session in document order, replayed by the course harness; the script’s lock TTLs are 200 ms so the expiry bug can be reproduced quickly.DELEXandSET ... IFEQneed Redis 8.4+; on older servers and on Valkey use the Lua release shown below.
A distributed lock lets several processes agree that only one of them works on a shared resource at a time. On a single Redis node the acquire is one atomic command; the hard parts are releasing safely and surviving the holder being slower than its lock.
Acquiring the lock
Section titled “Acquiring the lock”SET lock:resource token NX PX 30000:
- NX: set only if the key does not exist (atomic acquire).
- PX 30000: auto-release after 30 000 ms, so a crashed holder cannot block everyone forever.
- token: a unique value (a random UUID) only the holder knows; you need it to release and to renew safely.
Releasing and renewing safely
Section titled “Releasing and renewing safely”Deleting the key is only correct while it still holds your token. If your TTL expired and another worker acquired the lock, a plain DEL deletes their lock. “Check the value, then delete” is two commands, so it needs to be atomic on the server. Redis 8.4 added that as a single command, DELEX key IFEQ value (DELEX, since 8.4.0), and SET key value IFEQ current for renewing the TTL only while the lock is still yours. Before 8.4 the same is a Lua script (below).
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000OK127.0.0.1:6379> PTTL lock:payment(integer) 30000127.0.0.1:6379> SET lock:payment "token-uuid-xyz" NX PX 30000(nil)127.0.0.1:6379> DELEX lock:payment IFEQ "wrong-token"(integer) 0127.0.0.1:6379> SET lock:payment "token-uuid-abc" PX 30000 IFEQ "token-uuid-abc"OK127.0.0.1:6379> SET lock:payment "token-uuid-xyz" PX 30000 IFEQ "wrong-token"(nil)127.0.0.1:6379> GET lock:payment"token-uuid-abc"127.0.0.1:6379> DELEX lock:payment IFEQ "token-uuid-abc"(integer) 1127.0.0.1:6379> GET lock:payment(nil)127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000OK127.0.0.1:6379> EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "wrong-token"(integer) 0127.0.0.1:6379> EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "token-uuid-abc"(integer) 1127.0.0.1:6379> GET lock:payment(nil)- A second
SET ... NXfrom another holder returns(nil): the lock is taken. DELEX ... IFEQwith the wrong token returns0and leaves the key; with the right token it returns1.SET ... PX 30000 IFEQ tokenrenews the TTL only for the holder; a stranger gets(nil).- The Lua release does the same check-then-delete for servers older than 8.4. The
EVALform is the “right way” the earlier version of this lesson taught; it is still correct, just no longer the only option.
SET lock:payment "token-uuid-abc" NX PX 30000
PTTL lock:payment
SET lock:payment "token-uuid-xyz" NX PX 30000SET lock:payment "token-uuid-abc" NX PX 30000
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "token-uuid-abc"
GET lock:payment
SET lock:payment "token-uuid-abc" NX PX 30000
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) end return 0" 1 lock:payment "wrong-token"The lock in ioredis, and the bug it must survive
Section titled “The lock in ioredis, and the bug it must survive”// @runimport { randomUUID } from 'node:crypto';import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const KEY = 'b3:lock:payment';
const acquire = async (ttlMs: number) => { const token = randomUUID(); return (await redis.set(KEY, token, 'PX', ttlMs, 'NX')) === 'OK' ? token : null;};// release: delete the key only if it still holds MY token (Redis 8.4+: one command)const release = async (token: string) => (await redis.call('DELEX', KEY, 'IFEQ', token)) as number;// the same check as a Lua script, for Redis < 8.4 (and for Valkey)redis.defineCommand('releaseLua', { numberOfKeys: 1, lua: `if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end`,});declare module 'ioredis' { interface RedisCommander<Context> { releaseLua(key: string, token: string): Promise<number>; }}
await redis.del(KEY);
// 1) basic: one winner, the loser gets null, a wrong token cannot release, the right token canconst a = await acquire(30_000);console.log('A acquires:', a !== null, '| B acquires while A holds it:', await acquire(30_000));console.log('release with wrong token ->', await release('not-my-token'), '| with A token ->', await release(a!), '| key now:', await redis.get(KEY));
// 2) the bug: A's TTL (200 ms) expires while A is still working (400 ms); B takes the lock; A "releases"async function scenario(safeRelease: boolean) { await redis.del(KEY); const tokenA = await acquire(200); await sleep(300); // A is slow; its lock has expired const tokenB = await acquire(30_000); // B legitimately acquires await sleep(100); // A finishes and releases if (safeRelease) console.log(' A releases with token check ->', await release(tokenA!)); else { await redis.del(KEY); console.log(' A releases with plain DEL -> deleted B\'s lock'); } const tokenC = await acquire(30_000); // C arrives while B is still working console.log(' C acquires while B is still inside its critical section:', tokenC !== null, '| B still holds it:', tokenB !== null && (await redis.get(KEY)) === tokenB);}console.log('plain DEL release:'); await scenario(false);console.log('token-checked release:'); await scenario(true);
// 3) Lua release works the same (pre-8.4 path)await redis.del(KEY);const t = await acquire(30_000);console.log('Lua release wrong ->', await redis.releaseLua(KEY, 'nope'), '| right ->', await redis.releaseLua(KEY, t!));
// 4) fencing token: the lock gives out an increasing number; the RESOURCE rejects stale onesconst storage = { maxToken: 0, value: '' };function write(token: number, value: string) { // what the protected resource must do if (token < storage.maxToken) return false; storage.maxToken = token; storage.value = value; return true;}await redis.del('b3:lock:seq');const tokA = await redis.incr('b3:lock:seq'); // A gets 1, then stalls (GC pause, slow network)const tokB = await redis.incr('b3:lock:seq'); // its lock expired; B gets 2console.log('fencing: B writes with', tokB, '->', write(tokB, 'B'), '| A wakes up and writes with', tokA, '->', write(tokA, 'A'), '| value =', storage.value);
// 5) a small helper: acquire with retry + jitter, always release in finallyasync function withLock<T>(ttlMs: number, work: () => Promise<T>, waitMs = 1000): Promise<T | null> { const deadline = Date.now() + waitMs; let token: string | null; while (!(token = await acquire(ttlMs))) { if (Date.now() > deadline) return null; await sleep(20 + Math.random() * 30); } try { return await work(); } finally { await release(token); }}await redis.del(KEY);let inside = 0, maxInside = 0;await Promise.all(Array.from({ length: 5 }, () => withLock(5_000, async () => { maxInside = Math.max(maxInside, ++inside); await sleep(30); inside--;})));console.log('5 workers through withLock: max inside the critical section at once =', maxInside);
await redis.del(KEY, 'b3:lock:seq');await redis.quit();Real output (this Mac):
A acquires: true | B acquires while A holds it: nullrelease with wrong token -> 0 | with A token -> 1 | key now: nullplain DEL release: A releases with plain DEL -> deleted B's lock C acquires while B is still inside its critical section: true | B still holds it: falsetoken-checked release: A releases with token check -> 0 C acquires while B is still inside its critical section: false | B still holds it: trueLua release wrong -> 0 | right -> 1fencing: B writes with 2 -> true | A wakes up and writes with 1 -> false | value = B5 workers through withLock: max inside the critical section at once = 1What it proves:
- The basics: one winner, the loser gets
null, a wrong token cannot release, the right token can. - The expiry bug, both ways. A’s 200 ms lock expires while A is still working (300 ms in); B legitimately acquires; A then finishes and releases. With plain
DEL, A deleted B’s lock, so C acquired it while B was still inside its critical section: two holders at once. With the token check, A’s release returned0, B’s lock stayed, and C was refused. - The Lua release behaves identically, so use it on servers without
DELEX. - A TTL is not a guarantee of exclusion. Even with a token-checked release, A’s work after its lock expired still ran alongside B’s. A lock with a TTL is safe against a crashed holder, not against a slow one (long GC pause, stalled network, a stop-the-world). That is what a fencing token is for: the lock hands out an increasing number (
INCR), and the protected resource rejects any write with a smaller number than it has already seen. In the run, B wrote with2, and A, waking up with1, was refused. A lock alone cannot do this; the resource must take part. - A helper you can reuse.
withLockretries with jitter until a deadline, runs the work, and always releases infinally: 5 concurrent workers never overlapped (max inside = 1). Make the TTL comfortably longer than the work, and renew withSET ... IFEQ(or split the work) if it can run long.
Redlock for multi-node setups
Section titled “Redlock for multi-node setups”A single-node lock is lost if that node fails over to a replica that had not yet received the key, because Redis replication is asynchronous; the Redis documentation explains the scenario in its distributed locks page. For high availability the same page describes the Redlock algorithm: acquire the lock on N independent masters and treat it as held only when a majority (N/2+1) confirm within a time bound. In Node.js the maintained package is redlock (npm redlock, currently 5.0.0-beta.2; it works with ioredis). The package ioredis-redlock that some sources mention does not exist on npm (npm view ioredis-redlock returns 404, checked 2026-09-30).
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writes a lock helper and says it is “safe because it uses NX and a TTL”:
🐛 Spot the bug
async function withLock(name: string, work: () => Promise<void>) {const ok = await redis.set(`lock:${name}`, '1', 'PX', 10_000, 'NX');if (!ok) throw new Error('busy');try { await work(); // might take longer than 10 s} finally { await redis.del(`lock:${name}`); // release}}Show the answer
The value is the constant '1', so nothing says whose lock it is, and DEL deletes whatever is there. In the run above, when the holder was slower than its TTL, its DEL removed the next holder’s lock and a third worker walked in: two workers inside the critical section. Store a unique token and release only if it still matches (DELEX key IFEQ token on 8.4+, otherwise the Lua script):
const token = randomUUID();const ok = await redis.set(`lock:${name}`, token, 'PX', 10_000, 'NX');// ...work...await redis.call('DELEX', `lock:${name}`, 'IFEQ', token);Mental Model
Section titled “Mental Model”A Redis lock is a coat-check ticket with a timer: SET NX hands the ticket to the first person, the TTL takes it back if they vanish, and the token is the number on your stub. Handing the coat back with the wrong stub must do nothing. But a timer cannot tell a dead holder from a slow one, so if the work is dangerous to overlap, the thing being protected has to check a fencing number too.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
SET NX PX on a single node | Simple, fast, one command | If the node fails over to a replica that has not received the key, two holders can exist (asynchronous replication) |
Token + DELEX IFEQ | One command, atomic ownership check | Redis 8.4+ only |
| Token + Lua release | Works on every version and on Valkey | A script to maintain |
| Fencing token at the resource | Safe against slow holders too | The resource must support it (compare-and-set on a version) |
| Redlock (N masters) | Tolerates a node failure | More moving parts, timing assumptions, and the disagreement above |
DB row lock (SELECT ... FOR UPDATE) | Tied to the data’s own transaction | Slower and uses DB connections |
Common Misconceptions
Section titled “Common Misconceptions”- “NX plus a TTL makes a safe lock.” Acquire is safe; release needs a token check and slow holders need fencing.
- “
DELis fine, the TTL cleans up anyway.” PlainDELdeleted the next holder’s lock in the run above. - “Redlock makes locks always safe.” It removes the single-node failover hole; it does not remove pauses (Kleppmann vs antirez).
- “The TTL is how long the work takes.” It is an upper bound for a crashed holder; it must exceed the work.
Common mistakes
Section titled “Common mistakes”- Releasing with plain
DEL— deletes someone else’s lock after your TTL expired (reproduced). - A constant lock value — there is no ownership to check.
- A TTL shorter than the critical section — a second worker enters while the first still works.
SETthenPEXPIREas two commands — a crash between them leaves a lock that never expires; use the options onSET.- Expecting Redlock-level safety from one node — or expecting a lock alone to protect a resource without fencing.
Real-world example
Redis’s own documentation presents
SET resource_name my_random_value NX PX 30000with a token-checked delete as the correct single-instance lock and states the failover limitation, then presents Redlock for the multi-node case (Distributed Locks with Redis); Kleppmann’s 2016 article is the widely cited critique.