Skip to content

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. DELEX and SET ... IFEQ need 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.

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.

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).

cli/patterns-locks-1.redis
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000
OK
127.0.0.1:6379> PTTL lock:payment
(integer) 30000
127.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) 0
127.0.0.1:6379> SET lock:payment "token-uuid-abc" PX 30000 IFEQ "token-uuid-abc"
OK
127.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) 1
127.0.0.1:6379> GET lock:payment
(nil)
127.0.0.1:6379> SET lock:payment "token-uuid-abc" NX PX 30000
OK
127.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) 0
127.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) 1
127.0.0.1:6379> GET lock:payment
(nil)
  • A second SET ... NX from another holder returns (nil): the lock is taken.
  • DELEX ... IFEQ with the wrong token returns 0 and leaves the key; with the right token it returns 1.
  • SET ... PX 30000 IFEQ token renews 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 EVAL form 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 30000
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 "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”
src/patterns-locks.ts
// @run
import { 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 can
const 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 ones
const 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 2
console.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 finally
async 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: null
release with wrong token -> 0 | with A token -> 1 | key now: null
plain 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: false
token-checked release:
A releases with token check -> 0
C acquires while B is still inside its critical section: false | B still holds it: true
Lua release wrong -> 0 | right -> 1
fencing: B writes with 2 -> true | A wakes up and writes with 1 -> false | value = B
5 workers through withLock: max inside the critical section at once = 1

What 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 returned 0, 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 with 2, and A, waking up with 1, was refused. A lock alone cannot do this; the resource must take part.
  • A helper you can reuse. withLock retries with jitter until a deadline, runs the work, and always releases in finally: 5 concurrent workers never overlapped (max inside = 1). Make the TTL comfortably longer than the work, and renew with SET ... IFEQ (or split the work) if it can run long.

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).

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);

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.

ChoiceBenefitCost
SET NX PX on a single nodeSimple, fast, one commandIf the node fails over to a replica that has not received the key, two holders can exist (asynchronous replication)
Token + DELEX IFEQOne command, atomic ownership checkRedis 8.4+ only
Token + Lua releaseWorks on every version and on ValkeyA script to maintain
Fencing token at the resourceSafe against slow holders tooThe resource must support it (compare-and-set on a version)
Redlock (N masters)Tolerates a node failureMore moving parts, timing assumptions, and the disagreement above
DB row lock (SELECT ... FOR UPDATE)Tied to the data’s own transactionSlower and uses DB connections
  • “NX plus a TTL makes a safe lock.” Acquire is safe; release needs a token check and slow holders need fencing.
  • “DEL is fine, the TTL cleans up anyway.” Plain DEL deleted 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.
  • 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.
  • SET then PEXPIRE as two commands — a crash between them leaves a lock that never expires; use the options on SET.
  • 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 30000 with 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.

What does the NX flag do in SET lock:res token NX PX 30000?
Why store a unique token as the lock value?
A holder's 200 ms lock expires while it works, another worker acquires, then the first releases with plain DEL. What happens?
Which pair is the atomic 'delete only if mine' on Redis 8.4+ and on older servers?
What does a fencing token protect against that a token-checked release does not?
Which npm package implements Redlock for Node.js?
What is the main failure mode of a single-node SET NX PX lock that Redlock addresses?