Node.js Distributed Locking in 2026: Redlock, Postgres & Fencing
Two replicas of your Node.js worker pull the same job off the queue. Both try to charge the customer. Both succeed. The user pays twice. A second customer never gets charged at all because a race condition silently overwrote the row. This is the world without distributed locks — and it is the single most common production bug in any Node.js system that runs more than one process.
In 2026, distributed locking is no longer optional. Node.js apps run in clustered Kubernetes pods, on Fly.io Machines that scale to zero, on Cloudflare Workers spinning up in every region. The moment your code touches shared state — a database row, a Stripe customer, a queue job — you need a mechanism that guarantees only one process holds the critical section. This guide walks through the four production-grade approaches that actually work in 2026 — Redis Redlock, Postgres advisory locks, fencing tokens, and Etcd leases — with runnable code and the failure modes the docs hide. If you eventually need senior engineers to ship this, HireNodeJS connects you with pre-vetted Node.js developers who have done it on real production systems.
Why You Need Distributed Locks in 2026
Single-process Node.js apps don't need locks for in-memory state — the event loop is single-threaded, and a synchronous critical section runs atomically. The moment you scale horizontally, that guarantee disappears. A second instance shares no memory with the first. If both read a balance, decrement it, and write it back, you get a lost update. The same is true for cron jobs running on multiple replicas, for Bull/BullMQ workers competing for a job, and for webhooks that fire twice from Stripe or GitHub.
Common race-condition scenarios
Three patterns account for 90% of locking bugs in production Node.js services. First, leader election — only one instance should send the daily report email or run a database migration. Second, exclusive job processing — only one worker should process payment-42, even if two pulled it from SQS within milliseconds. Third, exactly-once semantics on retried webhooks — Stripe retries deliveries on 2xx timeouts, and your handler must not double-charge.
Why naïve solutions fail
Engineers reach first for SELECT FOR UPDATE on a flag row. It works at small scale, then collapses under contention: the row lock blocks the query for the entire critical section, the connection pool saturates, and p99 latency climbs into the seconds. Setting a Redis key with SET NX is closer — but a Node.js GC pause, a Kubernetes pod eviction, or a network partition can leave the key orphaned, and your next worker happily acquires a lock the previous holder still thinks it owns. Distributed locking is a correctness problem, not a convenience.

Redis Redlock — Multi-Node Mutual Exclusion
Redlock is Antirez's algorithm for acquiring a lock across N independent Redis nodes (typically 5). A client computes a unique random token, writes the key with SET NX PX <ttl> to every node in parallel, and considers the lock acquired only when a majority (3 of 5) accept the write within a short, bounded timeout. On release, the client uses a Lua script that deletes the key only if its value matches the token — preventing a stale holder from deleting someone else's lock.
Production setup with node-redis and @sesamecare/redlock
The Redlock implementation you want in 2026 is @sesamecare/redlock — it's a maintained fork of the original redlock package that fixes long-standing TypeScript and AbortSignal issues. Deploy 5 standalone Redis instances across independent failure domains (different AZs, ideally different VPCs). Do NOT use a single Redis cluster — Redlock's safety argument requires independence.
import Redis from 'ioredis';
import Redlock, { ResourceLockedError } from '@sesamecare/redlock';
// Five independent Redis nodes — different AZs
const nodes = [
new Redis({ host: 'redis-us-east-1a.internal', port: 6379 }),
new Redis({ host: 'redis-us-east-1b.internal', port: 6379 }),
new Redis({ host: 'redis-us-east-1c.internal', port: 6379 }),
new Redis({ host: 'redis-us-east-1d.internal', port: 6379 }),
new Redis({ host: 'redis-us-east-1e.internal', port: 6379 }),
];
export const redlock = new Redlock(nodes, {
driftFactor: 0.01, // 1% clock drift per second
retryCount: 10,
retryDelay: 200, // ms between retries
retryJitter: 100, // randomness on each retry
automaticExtensionThreshold: 500
});
// Usage — wraps the critical section, auto-extends, and releases on error
export async function withLock<T>(
resource: string,
ttlMs: number,
fn: (signal: AbortSignal) => Promise<T>
): Promise<T> {
return redlock.using([`lock:${resource}`], ttlMs, async (signal) => {
if (signal.aborted) throw signal.error;
return fn(signal);
});
}
// Example: only one worker processes payment-42 at a time
await withLock('payment:42', 5000, async (signal) => {
const charge = await stripe.charges.create({ amount: 1000, currency: 'usd', source: 'tok_x' });
if (signal.aborted) throw signal.error; // re-check before DB write
await db.insertCharge(charge);
});Postgres Advisory Locks — The Underrated Choice
If you already run Postgres in production, you already have a battle-tested distributed lock manager. Postgres advisory locks are application-level locks identified by a single bigint (or a pair of ints), held by a session, and released automatically when the session ends — including when the connection drops. They cost roughly 1ms to acquire under typical load, beat Redlock on latency in most benchmarks, and inherit Postgres's correctness and crash-recovery story.
Session-scoped vs transaction-scoped
pg_advisory_lock(key) is session-scoped — held until you call pg_advisory_unlock or the connection closes. pg_advisory_xact_lock(key) is transaction-scoped — released automatically when the transaction commits or rolls back. For 95% of use cases you want the transaction variant: it cannot leak across a request boundary, and it composes cleanly with your existing transaction code.
import { Pool } from 'pg';
import { createHash } from 'node:crypto';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Postgres advisory locks take a bigint — hash strings into the int8 range
function lockKey(resource: string): bigint {
const h = createHash('sha256').update(resource).digest();
// Take first 8 bytes as a signed bigint
return h.readBigInt64BE(0);
}
export async function withAdvisoryLock<T>(
resource: string,
fn: (client: import('pg').PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
const key = lockKey(resource);
try {
await client.query('BEGIN');
// pg_advisory_xact_lock blocks until acquired; use pg_try_advisory_xact_lock for non-blocking
await client.query('SELECT pg_advisory_xact_lock($1)', [key]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Example
await withAdvisoryLock('payment:42', async (client) => {
const { rows } = await client.query('SELECT status FROM payments WHERE id = $1', [42]);
if (rows[0].status === 'charged') return;
await client.query('UPDATE payments SET status = $1 WHERE id = $2', ['charged', 42]);
});Fencing Tokens — Why Locks Alone Aren't Enough
Martin Kleppmann's famous critique of Redlock is not that it doesn't acquire locks correctly — it's that locks alone cannot guarantee mutual exclusion on the resource you're protecting. Consider this sequence: worker A acquires the lock, hits a 20-second GC pause, the lock expires, worker B acquires it and writes data, then worker A wakes up and writes its (now stale) data over the top. Both workers believed they held the lock. The fix is fencing tokens — monotonically increasing IDs that the resource itself validates.
Implementing fencing on top of Redlock
Hire Pre-Vetted Node.js Developers
Skip the months-long search. Our exclusive talent network has senior Node.js experts ready to join your team in 48 hours.
Redlock can return a fencing token by combining the lock value with a Lua INCR counter on the primary Redis node. Every protected write to the resource (database row, S3 object metadata, Stripe metadata field) must include the token. The resource service rejects any write whose token is lower than the last accepted one.
// Acquire a lock that also returns a monotonic fencing token
export async function acquireWithFencingToken(resource: string, ttlMs: number) {
const lock = await redlock.acquire([`lock:${resource}`], ttlMs);
// Increment a counter that survives lock release — never resets
const token = await nodes[0].incr(`fence:${resource}`);
return { lock, token: BigInt(token) };
}
// The resource service refuses stale writes
export async function safeUpdatePayment(
paymentId: string,
token: bigint,
patch: Record<string, unknown>
) {
const result = await db.query(
`UPDATE payments
SET data = data || $1, last_token = $2
WHERE id = $3 AND ($2 > last_token OR last_token IS NULL)
RETURNING id`,
[JSON.stringify(patch), token.toString(), paymentId]
);
if (result.rowCount === 0) {
throw new Error(`stale token ${token} for ${paymentId} — write rejected`);
}
}
Etcd Leases — When You Need Strong Consensus
Redlock is fast and adequate for most workloads, but its safety argument leans on bounded clock drift between Redis nodes and the client. For correctness-critical work — leader election, schema migrations, configuration propagation — you want a system that uses a consensus protocol (Raft) instead. Etcd is the standard choice: it's the lock manager behind Kubernetes itself, and the Node.js etcd3 client gives you leases, watches, and atomic compare-and-swap in roughly 100 lines of code.
Election pattern with etcd3
import { Etcd3 } from 'etcd3';
const client = new Etcd3({ hosts: ['etcd-1:2379', 'etcd-2:2379', 'etcd-3:2379'] });
// Run as leader — re-attempted forever; only one process at a time wins
export async function runAsLeader(role: string, leaderFn: (signal: AbortController['signal']) => Promise<void>) {
while (true) {
const election = client.election(role, 10 /* seconds */);
const campaign = election.campaign(`pid-${process.pid}`);
await campaign.wait(); // resolves when we become leader
console.log(`[${role}] elected leader`);
const controller = new AbortController();
campaign.on('elected', () => {});
campaign.on('error', (err) => {
console.error(`[${role}] lost leadership:`, err);
controller.abort(err);
});
try {
await leaderFn(controller.signal);
} catch (err) {
console.error(`[${role}] leader work failed`, err);
} finally {
await campaign.resign();
}
}
}
// Usage — only one replica sends the daily digest
runAsLeader('daily-digest', async (signal) => {
for (const user of await getActiveUsers()) {
if (signal.aborted) return;
await sendDigest(user);
}
});Production Failure Modes Nobody Documents
Clock drift and the leap second
Redlock's TTL math assumes bounded clock drift across your Redis nodes. AWS, GCP, and Azure all smear the leap second across 24 hours — but if you mix providers, or run on bare metal with NTP misconfigured, drift can exceed the driftFactor budget. Symptom: locks appear to release before TTL on some nodes and after on others. Fix: monitor clock skew with chrony, alert above 100ms, and add a 2x safety margin to your TTL.
GC pauses and Kubernetes throttling
Node.js V8 GC pauses are usually <50ms, but CPU-throttled containers (low CPU limits in K8s) routinely see pauses of 1–5 seconds. During the pause, your lock can expire and a second worker can acquire it. Always treat TTL > realistic-pause + work-duration * 1.5, and re-check signal.aborted before any non-idempotent operation.
Network partitions split your quorum
If your 5 Redis nodes split 3-2 across a network partition, both sides will reject lock acquisitions — which is correct (no quorum). But if you misconfigure to only contact 3 nodes (a majority of 3 is 2), a 2-1 partition lets both halves grant the lock. The lesson: always contact all N nodes, always require strict (N/2 + 1) responses, never optimize for fewer round trips.
Hire Expert Node.js Developers — Ready in 48 Hours
Distributed locking is the kind of system where the bug doesn't show up in dev or staging — it shows up at 2am on Black Friday when two payment workers double-charge a customer. The fix is rarely a code change; it's hiring someone who has shipped this pattern at scale before. HireNodeJS.com specialises exclusively in Node.js talent: every developer is pre-vetted on real-world projects, distributed-systems primitives, Redis/Postgres internals, and production incident response.
Unlike generalist platforms, our curated pool means you speak only to engineers who live and breathe Node.js. Most clients have their first backend developer working within 48 hours of getting in touch. Engagements start as short-term contracts and can convert to full-time hires with zero placement fee. If you also need help wiring this into Kubernetes or Terraform, our DevOps engineers cover the infrastructure side of the same problem.
Choosing the Right Locking Strategy
If you only run Postgres, start with advisory locks — you get the lowest latency, the best correctness story, and zero new infrastructure. If you need cross-database independence (your workers don't share a DB), reach for Redis Redlock with @sesamecare/redlock and add fencing tokens for any non-idempotent write. If correctness matters more than latency — leader election for migrations, config propagation, anything where a wrong answer is unrecoverable — use Etcd. Avoid ZooKeeper unless you already run it; the operational cost dwarfs the benefit for Node.js teams in 2026.
Read more on related topics on our blog — our guides on Node.js concurrency patterns, BullMQ job queues, and idempotency patterns pair directly with the locking strategies covered here. Together they form the correctness backbone of any production Node.js system.
Summary — Locks Are a Correctness Tool, Not a Performance One
Distributed locks are how you stop two Node.js processes from corrupting each other's writes. In 2026, the four production-grade options are Postgres advisory locks (cheap, fast, correct if you already run Postgres), Redis Redlock (independent, good latency, needs fencing tokens for non-idempotent work), Etcd (consensus-backed, strongest safety, higher latency), and ZooKeeper (rarely the right choice unless legacy). Pick the simplest tool that fits your correctness budget.
Whichever you choose, the test is the same: kill processes mid-lock, partition the network, and verify the system either makes progress safely or fails loudly. If it silently grants two locks for the same resource, your locking layer is wrong — and that bug will eventually cost a customer. If you'd rather not learn that the hard way, HireNodeJS finds you the senior Node.js engineer who has already lived through it.
Frequently Asked Questions
When should I use Redis Redlock vs Postgres advisory locks for Node.js?
Use Postgres advisory locks when your workers already share a Postgres database — they are faster, cheaper, and correct by construction. Use Redis Redlock when your workers run against different databases (or no database) and need a shared lock manager. Always add fencing tokens with Redlock if you do non-idempotent writes.
Are Redis Redlock and SETNX the same thing?
No. SETNX writes a key to a single Redis node — if that node fails or partitions away, you lose mutual exclusion. Redlock writes to N independent Redis nodes (typically 5) and only considers the lock held when a majority accept the write within a timeout, surviving single-node failures.
What is a fencing token and why do I need one?
A fencing token is a monotonically increasing ID returned with the lock. Every protected write to the resource must include the token, and the resource rejects writes with a lower token than the last accepted one. This prevents a worker that paused (GC, throttling) and lost the lock from overwriting newer data after it wakes up.
How long should a Node.js distributed lock TTL be?
TTL must be longer than your worst-case critical section plus realistic pause time (GC, K8s CPU throttling, network blip), with a 2x safety margin. For a 200ms critical section, 5 seconds is a reasonable default. Always re-check the AbortSignal before any non-idempotent operation.
Can I use Redlock with Redis Cluster or a single Redis node?
No. Redlock requires N independent Redis instances (not a cluster) deployed across separate failure domains. A single node defeats the purpose; a cluster shares a single failure model. Run 5 standalone Redis nodes across different availability zones for production Redlock.
How much does it cost to hire a Node.js developer who knows distributed systems?
Senior Node.js engineers with distributed-systems experience typically cost $80–$160/hour on contract in 2026, depending on region. HireNodeJS pre-vets every developer on real-world distributed patterns including locking, queues, and consensus — and connects you with one within 48 hours, with no recruiter fees.
Vivek Singh is the founder of Witarist and HireNodeJS.com — a platform connecting companies with pre-vetted Node.js developers. With years of experience scaling engineering teams, Vivek shares insights on hiring, tech talent, and building with Node.js.
Need a Node.js engineer who has shipped distributed systems?
HireNodeJS connects you with pre-vetted senior Node.js engineers experienced in Redis, Postgres, and distributed-systems patterns. First developer available within 48 hours — no recruiter fees.
