Distributed Locking with Redis Redlock in Node.js

Photo by Towfiqu barbhuiya on Unsplash
Redlock is a distributed locking algorithm for Redis, described by Redis creator Salvatore Sanfilippo, that acquires a lock across a majority (quorum) of independent Redis nodes so only one process at a time enters a critical section. In Node.js the node-redlock library implements it. It is typically used to stop multiple app instances from doing the same work at once.
It is debated. Martin Kleppmann argued Redlock is unsafe for strict correctness under process pauses and clock skew and that you need fencing tokens; Sanfilippo responded defending it. For efficiency locks (avoiding duplicate work) Redlock is fine, but for correctness where the lock must never be violated, add a fencing token or use a system that provides one.
Every Redlock lock has a TTL, so if the holder crashes the lock expires automatically and the system does not deadlock. If your work might outlast the TTL, renew (extend) the lock periodically; node-redlock's using() helper auto-extends the lock while your callback runs. Keep the TTL longer than expected work but short enough to recover quickly.
If you already run a single Postgres, a session or transaction advisory lock, or SELECT ... FOR UPDATE SKIP LOCKED, gives strong mutual exclusion from one source of truth without extra infrastructure. Redlock fits when Redis is your shared coordination layer or you want locks independent of the database. For a single-database app the Postgres lock is usually simpler and safer.
When several app instances or workers could perform the same action concurrently, such as issuing one invoice number, processing a job exactly once, or preventing double stock allocation. If a single worker, a database unique constraint, or an idempotency key already prevents duplicates, you may not need a distributed lock at all.

Photo by Towfiqu barbhuiya on Unsplash
Key Takeaway
Distributed locking stops two Node.js instances from touching the same resource at once. Redis Redlock acquires a lock across a majority of independent Redis masters with a TTL and auto-extension. But a lock alone is not correctness: Martin Kleppmann shows fencing tokens are required, and a Postgres advisory lock often fits single-database work better.
The first time I ran two copies of the same Node.js service behind a load balancer, a scheduled job fired twice within the same second. Both replicas read the same pending invoice, both posted it to the ledger, and a customer got billed twice. Nothing in my code was wrong on a single machine. The bug only existed because there were now two machines, and neither knew the other was awake.
That is the multi-instance mutual-exclusion problem. A mutex or an in-process flag protects one process; it does nothing across a fleet. To make only one worker act at a time, you need a lock that lives outside every instance, in shared infrastructure they all trust. Redis is the tool most teams reach for, and Redlock is the algorithm Redis itself proposes for doing it safely.
Before the distributed version, it helps to get one Redis node right, because Redlock is just this pattern repeated across several nodes. You acquire a lock with a single atomic command: set a key only if it does not already exist, attach a unique random value, and give it an expiry. The random value is the part people skip, and it is the part that keeps you safe.
Why the random value matters: without it, a client whose work overran the expiry could delete a lock that a second client had already legitimately taken. So release is never a plain delete. You run a compare-and-delete that removes the key only if its stored value still equals the one you wrote. The official docs express this as a Lua script, and Redis 8.4 adds a native command for the same check.
SET resource_name my_random_value NX PX 30000
# Safe release (Redis < 8.4): compare value first, then delete
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endA single node is a single point of failure, and a primary-replica setup does not fix it because Redis replication is asynchronous: a primary can acknowledge a lock, crash before the write reaches the replica, and the promoted replica hands the same lock to someone else. Redlock avoids this by running several fully independent masters with no replication between them. The canonical setup is five nodes, and the client works through them in order:
The majority rule is what buys fault tolerance: as long as more than half the masters are up, locks still work, and a client that took longer than the TTL to gather the quorum must treat the lock as invalid. Effective validity is always TTL minus the acquisition time minus a small clock-drift margin, never the full TTL you asked for.
In Node.js the mike-marcacci node-redlock library implements all of this. You hand it an array of clients, one per independent master, plus tuning options: driftFactor for the clock-drift margin, retryCount and retryDelay with retryJitter to desynchronise competing clients, and automaticExtensionThreshold, the milliseconds of validity remaining before the library renews the lock on its own.
import Client from "ioredis";
import Redlock from "redlock";
// Independent masters — no replication between them
const redlock = new Redlock(
[new Client(6379), new Client(6380), new Client(6381)],
{
driftFactor: 0.01, // multiplied by ttl to derive drift
retryCount: 10,
retryDelay: 200, // ms between attempts
retryJitter: 200, // random ms to desynchronise clients
automaticExtensionThreshold: 500, // ms left before auto-extend
}
);
// using(): auto-extends while the routine runs, releases at the end
await redlock.using(["invoice:4821"], 5000, async (signal) => {
const invoice = await loadInvoice("4821");
// If an extension failed, stop before doing damage
if (signal.aborted) throw signal.error;
await postToLedger(invoice);
});
// Manual acquire / extend / release when you need the handle
let lock = await redlock.acquire(["invoice:4821"], 5000);
try {
lock = await lock.extend(5000); // returns a NEW Lock instance
} finally {
await lock.release();
}The safest entry point is using(): you pass resources, a duration, and a routine, and the library holds the lock for exactly as long as your function runs, extending it in the background before it lapses and releasing it when you return. It hands your routine a signal, and you must check signal.aborted before any irreversible step, because a failed extension means you may no longer hold the lock even though your code is still running.
Set the TTL to the longest you can tolerate a crashed holder blocking the resource, not to how long the job takes. A short TTL with auto-extension is far safer than a long one, because if the process dies the lock frees quickly, while extension keeps a healthy long job protected. Never assume the lock is still yours just because your process is still alive.
In 2016 Martin Kleppmann published the critique every user of Redlock should read. His central point is a distinction: you lock either for efficiency, where a rare double-run only wastes work, or for correctness, where a double-run corrupts data. Redlock, he argues, is fine for the first and unsafe for the second, and the reason is timing. A stop-the-world garbage-collection pause, a long network delay, or a wall-clock jump can leave a client convinced it still holds a lock the TTL already expired.
His fix is the fencing token: every acquisition returns a number that only ever increases, the client sends it with each write, and the protected resource rejects any write carrying a token lower than the highest it has seen. That turns a late write from a stale holder into a harmless rejection. Redlock does not generate such a token by default, and its unique random value is not monotonic, so if you need correctness you must add fencing yourself. The Redis docs now recommend exactly this.
Reach for Redlock only when the resource you are guarding lives outside any single database. If the work already happens inside Postgres, Postgres gives you coordination for free and without extra infrastructure. An advisory lock is an application-defined lock the database tracks but does not attach to any row; the transaction-scoped variant releases automatically when the transaction ends, so a crashed worker never leaves a lock stuck. For pulling jobs off a queue table, SKIP LOCKED lets each worker claim a different row without blocking on the others.
| Concern | Redis Redlock | Postgres advisory lock / SKIP LOCKED | Single-node SETNX |
|---|---|---|---|
| Infrastructure | Five independent Redis masters | The database you already run | One Redis node |
| Fault tolerance | Survives a minority of nodes failing | Tied to the database availability | None, a single point of failure |
| Correctness without fencing | Not guaranteed under pauses or clock skew | Strong, the lock lives with the data | Weak, acceptable only for efficiency |
| Best fit | Resources spread across services | Work already inside one Postgres | Non-critical single-instance jobs |
The transaction-scoped advisory lock and SKIP LOCKED cover the large majority of coordination I actually need in a Node.js backend, and they never introduce a second datastore whose failure modes I have to reason about separately. I only escalate to Redlock when the thing being locked genuinely spans systems that share no database.
My rule is simple. If the lock is for efficiency and everything touches one Postgres, I use an advisory lock or SKIP LOCKED. If the resource spans services, I use node-redlock with a short TTL and using() for auto-extension. And the moment correctness is on the line, I stop trusting the lock by itself and add fencing tokens, exactly as Kleppmann and the Redis docs both insist. A lock reduces the odds of a collision; a fencing token is what actually makes the write safe.