Lesson 9 ended on the point that everything depends on how far the clocks are skewed. A distributed lock is the most direct example: it is a mechanism resting on an assumption about time, and when that assumption is wrong, two workers both believe they hold the lock — while Redis behaves entirely correctly and no command fails.

One term used throughout the lesson needs saying up front: a critical section is a stretch of code where only one worker may run at a time — for instance the part that reads a balance, computes something, and writes the new balance back. Inside a single-threaded JavaScript process you barely ever meet the concept, because nothing runs in parallel. But once there are two processes on two machines it returns — and the language can no longer protect it for you.

This lesson's lab has two real Node workers contend for one Redis lock and counts the conflicts: how often two or more workers were inside the critical section at once. One of the results made me re-run it several times to be sure: a lock that is implemented entirely correctly, but with a TTL shorter than the work time, produced 79 conflicts in 80 entries into the critical section — worse than using no lock at all (41/80). A broken lock is more dangerous than no lock, because it gives you confidence.

ℹ️ The measurement machine
Apple M1 Max, 10 cores, 32 GB RAM, macOS 26.5.2, Docker 29.6.2. Native arm64 containers (Node v22.23.2), Redis 7-alpine. Two worker containers run the same file, worker/lock-worker.js, each limited to 1 CPU, each completing 40 critical sections (80 in total). Simulated work time is 300 ms per pass.

The conflict counter lives in Redis and is updated by a Lua script — meaning it is atomic, so the count itself is not corrupted by the very race it is measuring.

10.1 Two completely different needs

Before discussing how to implement a lock, you have to separate the two reasons for using one. Most material collapses them into one, and that is the origin of every distributed-lock accident.

A lock for EFFICIENCY A lock for CORRECTNESS
Purpose Avoid doing duplicate work Stop two workers modifying the same data
What failure costs Wasted CPU, an email sent twice, a thumbnail generated twice Wrong data, money debited twice, stock lost from inventory
Is a distributed lock enough? Yes — occasional failure is acceptable NO — you need fencing tokens, or better still, no lock at all
The right tool A Redis lock is a reasonable choice Database transactions, or idempotency (Lesson 11), or partitioning by key
⚠️ Pitfall: using a distributed lock for correctness in money-handling logic
A distributed lock does not give you that guarantee, and section 10.3 proves it with numbers. The deeper reason: a lock only says "at the moment you asked, nobody else held it". It says nothing about the moment you write, which happens later — and between those two moments your process can be stopped for longer than the TTL.

If two workers running together would lose money, the right tools are database transactions (real isolation, not dependent on clocks), idempotency (repeating does no harm, so there is nothing to prevent), or partitioning by key (each key has exactly one worker, so there is nothing to contend for). Distributed locks remain useful — just for the efficiency category.

10.2 The three mandatory parts of a Redis lock

A correct Redis lock needs exactly three things, and missing any one of them produces its own distinct bug:

worker/lock-worker.js — acquiring and releasing the lock
/**
 * The three MANDATORY parts of a correct Redis lock (Lesson 10, section 10.2):
 *   NX      only set if the key does not exist — this is the "acquire" part
 *   PX ttl  a TTL is MANDATORY, otherwise a dead holder is a permanent deadlock
 *   token   a random value, so ONLY the owner can delete it (see release)
 */
async function acquire(key, token, ttlMs) {
  const res = await redis.cmd('SET', key, token, 'NX', 'PX', String(ttlMs));
  return res === 'OK';
}

/**
 * Releasing MUST be atomic: check the token and only then DEL, in one command.
 *
 * Why a plain `DEL` is wrong: your lock may already have EXPIRED and someone else may
 * have acquired it. A plain `DEL` would delete THEIR lock, and from there everything
 * breaks in a chain.
 */
const RELEASE_LUA = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end`;

async function release(key, token) {
  return redis.cmd('EVAL', RELEASE_LUA, '1', key, token);
}
What is missing The bug it creates How it shows up
No NX There is no lock at all — whoever writes, wins Conflicts from the very start, easy to spot
No TTL Permanent deadlock when the holder dies The system stalls and does not recover; you have to delete the key by hand
No token A worker deletes another worker's lock Random conflicts, only after one expiry has occurred — very hard to reproduce
Non-atomic release The same bug, just narrower: the window between GET and DEL Rarer, and therefore even harder to find

Measured for real: does the lock do its job?

Two workers, each completing 40 critical sections (80 in total), with 300 ms of work each. The CONFLICT counter increases whenever two or more workers are inside the critical section at once:

Configuration Critical-section entries Conflicts Rate
A · No lock 80 41 51%
B · Correct lock, TTL 1000 ms > work 300 ms 80 0 0%
C · Correct lock but TTL 200 ms < work 300 ms 80 79 99%

Row B is what we expect: the lock works, 0 conflicts. Row C is the one worth stopping on for a long time. The lock in row C is implemented entirely correctly — it has NX, a TTL, a token, and a Lua release. There is no GC pause and no clock skew. It has exactly one wrong parameter: a TTL shorter than the work time. And the result is 99% — worse than using no lock.

🔬 Why a short TTL is worse than no lock
The mechanism is simple once you see it: worker A takes the lock and starts 300 ms of work. At the 200 ms mark the lock expires by itself while A is still working. Worker B immediately acquires it and enters the critical section — now there are two workers inside. This repeats on almost every round, which is why the rate is 99% rather than some random figure.

Why worse than no lock? Because without a lock it is 51% — the two workers run freely and sometimes drift out of phase. With a short-TTL lock, the lock synchronises them: B always starts exactly when A's lock expires, which is exactly when A still has 100 ms of work left. The lock has turned random overlap into systematic overlap.

The practical consequence: the TTL must exceed the worst-case work time, not the average. And if you do not know what the worst case is — extremely common when the critical section contains a network call — then you cannot set the TTL correctly, and that is the sign to choose another approach (section 10.5).
A GC pause longer than the TTL — Redis is entirely correct, and the lock still fails 0 1000 ms 2000 3000 4000 WORKER A takes the lock GC PAUSE 1200 ms — the process is stopped WRITE A still BELIEVES it holds the lock — but the lock expired back at 1000 TTL 1000 ms ← the lock expires BY ITSELF here WORKER B takes the lock WRITE B acquires the lock legitimately — it does nothing wrong BOTH are inside the critical section Measured: a 1200 ms pause > the 1000 ms TTL 31 conflicts / 80 entries into the critical section = 39% No Redis command failed. No log looked unusual. Why adding Redis nodes does NOT help The problem is an assumption about the PROCESS's timing, not about Redis's reliability. 5 nodes change nothing. This is the core of Kleppmann's argument about Redlock. GC pauses are not hypothetical: a JVM stop-the-world, a migrated VM, a CPU-throttled container, or a machine that starts swapping — all of them stop a process for seconds.
The point to remember: a lock answers "who holds it right now?" but what you need is the answer at the moment of the write — and nothing guarantees anything in between.

10.3 Why Redlock is controversial

Redlock is an algorithm proposed to make distributed locking safer: acquire the lock on a majority of 5 independent Redis nodes. Martin Kleppmann argued against it, and the core of the argument is very compact — the problem is not Redis.

The lab experiment reproduces exactly that argument. The same correct lock, the same TTL of 1000 ms, longer than the 300 ms of work:

Configuration Critical-section entries Conflicts Comment
B · TTL 1000, no pause 80 0 The lock does its job
D · TTL 1000, GC pause 1200 ms 80 31 (39%) No Redis command failed. Redis behaved perfectly

The mechanism: worker A takes the lock, then is stopped for 1200 ms — a stop-the-world GC, a migrated VM, a CPU-throttled container, or a machine that started swapping. Meanwhile the lock expires at the 1000 ms mark and worker B acquires it entirely legitimately. A wakes up at 1200 ms and still believes it holds the lock — it has no way to know that time has passed. It writes. Now two workers are writing.

⚡ Adding Redis nodes does not fix this
This is the most important point of the section. If you are thinking "5 Redis nodes are safer than 1", re-read experiment D: Redis did nothing wrong. It accepted the SET NX PX, it expired the key at the right moment, it handed the lock to B exactly per the protocol. The fault lies in the assumption that your process will not be stopped for longer than the TTL — an assumption about the client side, not about Redis.

Adding four more nodes only makes the "acquire" step more resilient to Redis's own failures. It does not stop your process being paused by GC, and it does not tell that process that time has passed.

10.4 Fencing tokens — the right answer for the correctness category

If a lock cannot guarantee "only one worker writes", the check has to move somewhere else: to the target resource itself. The lock issues a monotonically increasing number — a fencing token — and every write must carry it. The resource remembers the highest token it has ever seen and rejects anything lower.

That is how a "zombie" worker returning after a GC pause gets blocked — not because it knows it lost the lock, but because the resource knows somebody newer has been through.

worker/lock-worker.js — the fencing token
// Fencing token: a MONOTONICALLY INCREASING number, issued by Redis via INCR (atomic).
// Taken AFTER acquiring the lock, so token order matches lock-acquisition order.
const fence = USE_FENCE ? Number(await redis.cmd('INCR', 'lab:fence:seq')) : 0;

/**
 * Write to the "resource", with fencing-token checking (Lesson 10, section 10.4).
 *
 * This is the crux: the target resource itself rejects any token LOWER than the highest
 * one it has seen. That is how a "zombie" worker returning from a GC pause — still
 * believing it holds the lock — gets blocked AT THE RESOURCE LAYER rather than by the
 * lock. If the resource does not check, the fencing token is just a decorative number.
 */
const FENCED_WRITE_LUA = `
local seen = tonumber(redis.call('GET', KEYS[1]) or '0')
local tok  = tonumber(ARGV[1])
if tok < seen then
  redis.call('INCR', KEYS[3])          -- count the REJECTED writes
  return 0
end
redis.call('SET', KEYS[1], tok)
redis.call('INCR', KEYS[2])            -- count the ACCEPTED writes
return 1`;

// In the worker: only do the work if the resource ACCEPTED the token.
let accepted = true;
if (USE_FENCE) accepted = (await fencedWrite(fence)) === 1;
if (accepted) await sleep(WORK_MS);
Fencing tokens — the resource remembers the highest token it has seen and rejects lower ones worker A worker B lock (issues tokens) resource 1 · A takes the lock → receives token 33 GC PAUSE 2 · A's lock expires while A is stopped 3 · B takes the lock → token 34 4 · B writes with token 34 ACCEPTED highest seen: 34 5 · A wakes up, still BELIEVING it holds the lock, and writes with token 33 REJECTED 33 < 34 already seen Measured: 15 writes rejected out of 80 entries into the critical section · 65 accepted · 65 + 15 = 80.
A never learns that it lost the lock — and it does not need to. The check lives at the resource, the only place with complete information.

The same configuration that broke in experiment D — TTL 1000 ms, GC pause 1200 ms — but with fencing on:

Configuration Conflicts Writes accepted Writes REJECTED
D · lock, pause 1200 > TTL 1000 31
E · lock + fencing, same pause 15 65 15
🔬 Read this table correctly: fencing does not prevent conflicts
The "conflicts" column in row E is still 15, not 0 — and that is not a failure. A fencing token does not stop two workers entering the critical section; only the lock does that, and the lock was broken by the GC pause. Fencing prevents the damage: 15 writes from the zombie worker were rejected by the resource, and 65 + 15 = 80 matches the total number of critical-section entries exactly.

Why 15 rather than the 31 of row D? Because when a write is rejected the worker skips its 300 ms of work — so its critical section is much shorter and overlaps far less. That is a consequence of the lab design, and it also reflects reality accurately: a zombie blocked early causes less disturbance.

The right reading: the lock is the prevention layer, fencing is the guarantee layer. The lock reduces contention (and thereby preserves throughput); fencing ensures that when the lock does fail, no data is written wrongly. The "correctness" category from section 10.1 needs both.
⚠️ Pitfall: implementing fencing where the resource does not check the token
This is the most common way to get fencing wrong: the worker takes a token, passes it around, logs it — but the target resource compares nothing. At that point the token is just a decorative number, and you have all the feeling of safety with none of the safety.

The test question: can your resource REJECT a write? If it only knows how to accept, fencing does not apply. With a database the equivalent and very pragmatic mechanism is optimistic concurrency: UPDATE ... WHERE version = :expected — zero rows affected is exactly "rejected". It needs no lock, no separate token, and the database already has it.

10.5 Avoiding locks entirely — usually the best answer

After the four sections above, the pragmatic conclusion is: distributed locks are hard to get right, and even when correct they still do not provide the guarantee the "correctness" category needs. So in most cases the better question is not "how do I implement the lock" but "is there a way to not need one?"

Approach How it works Use when
A built-in atomic operation INCR, SETNX, UPDATE ... SET n = n + 1 The operation fits in one command. Cheapest and most certain — there is nothing to contend for because there is no interval in between
Optimistic concurrency UPDATE ... WHERE version = :expected, 0 rows affected ⇒ retry Conflicts are rare. No lock is held, so no deadlocks and no TTL to guess
Idempotency (Lesson 11) Doing it twice gives the same result, so there is no need to prevent the second time The operation can be redesigned to be idempotent. This is usually the most correct answer, not merely the lock-avoiding one
Partitioning by key (Lesson 12) Each key always goes to one fixed worker ⇒ no two workers ever touch the same key You have a partitioned queue. It turns a mutual-exclusion problem into a routing problem — which is far easier
Database transactions Real isolation, not dependent on clocks or TTLs The data already lives in a database. This is the right tool for money-handling logic
⚠️ Pitfall: holding a lock across a network call
If the critical section contains an HTTP call or a database query, then the lock-hold time depends on something you do not control. Two consequences at once: the TTL cannot be set correctly (you do not know an upper bound for a network call — see row C in section 10.2 again), and throughput is capped by the slowest dependency, because every other worker is queued waiting for the lock.

And there is a twin pitfall: a lock that is too coarse. One lock for a whole table instead of one per record turns a parallel system into a serial one. If you must use a lock, scope it as narrowly as possible — per order_id, not per orders.
Three solutions to the same problem — only the first requires guessing a TTL 1 · DISTRIBUTED LOCK w1 w2 LOCK resource You must guess the TTL Fails when a GC pause > TTL Throughput becomes serialised Measured: 31/80 conflicts when the pause exceeds the TTL 2 · ATOMIC OPERATION w1 w2 INCR / CAS atomic out of the box No TTL to get wrong No window in between Both workers keep running Limit: only usable when it fits in ONE command 3 · PARTITION BY KEY keys A,C,E keys B,D,F w1 w2 Nothing to contend for: each key is handled by exactly ONE worker Turns a mutual-exclusion problem into a ROUTING problem In exchange: per-key hotspots (Lesson 8) The order to try: (2) atomic → (3) partitioning → transactions/idempotency → and only when nothing else works, (1). Option 1 is the only one that has to answer "what TTL is enough" — a question that usually has no correct answer.
Distributed locks are not wrong — they are simply the most expensive of the three, so they should be the last resort rather than the first reflex.

Reproduce the measurements yourself

reproduce_measurements.sh
cd blog/sysdesign/sysdesign-lab
docker compose --profile lock up -d redis

# ./tools/lock-test.sh <LOCK> <FENCE> <PAUSE_MS> <TTL> <ROUNDS> <WORK_MS>
# The number that matters most is CONFLICTS: how often TWO OR MORE workers were
# inside the critical section at the same time.

./tools/lock-test.sh off 0 0    1000 40 300   # A: no lock         -> 41/80
./tools/lock-test.sh on  0 0    1000 40 300   # B: correct lock    ->  0/80
./tools/lock-test.sh on  0 0     200 40 300   # C: TTL < work time -> 79/80 (!)
./tools/lock-test.sh on  0 1200 1000 40 300   # D: GC pause > TTL  -> 31/80
./tools/lock-test.sh on  1 1200 1000 40 300   # E: + fencing -> 15 writes REJECTED

# A NOTE ON EXPERIMENT DESIGN: WORK_MS must be long enough relative to PAUSE_MS.
# Otherwise the other worker has already left the critical section by the time the
# "zombie" wakes up, and you measure 0 conflicts even though the lock really did fail.
# The first version of this lab made exactly that mistake.

In summary

You have to separate locks for efficiency (failure costs CPU) from locks for correctness (failure costs money). A distributed lock is only sufficient for the first, and the whole of section 10.3 is numerical evidence for that.

A correct Redis lock needs all three: NX, a TTL, and a random token with an atomic release. But being correct is still not enough — the strongest finding of this lesson is row C: a textbook-correct lock with a 200 ms TTL, shorter than the 300 ms of work, produced 79 conflicts out of 80, worse than using no lock at all (41/80). Because the lock synchronised the two workers into overlapping systematically instead of randomly.

With a GC pause longer than the TTL, conflicts were 31/80 — and not a single Redis command failed. That is why adding Redis nodes does not fix it: the wrong assumption is on the client side. Fencing tokens are the answer, but read them correctly: they do not prevent conflicts (still 15/80), they prevent damage — 15 writes rejected by the resource. And they only work if the resource genuinely checks the token.

Most pragmatic of all: avoid the lock. Atomic operations, optimistic concurrency, partitioning by key, transactions — none of them has to answer "what TTL is enough", a question that usually has no correct answer.

Lesson 11 goes into the strongest lock-avoidance option on that list: idempotency. If doing it twice gives the same result, there is no need to prevent the second time — and that is also the answer to a question every distributed system has to face: when a client receives a timeout, it cannot know whether the request was processed or not.

📖 References

Download the lab source

The worker that produced all five rows of measurements in this lesson — lock acquisition, atomic Lua release, the simulated GC pause, and a fencing token checked at the resource. No dependencies at all, including no Redis client:

Download lock-worker.js

Related lessons in this series

Lesson 9: CAP & Consistency Models Lesson 11: Idempotency & Safe Retries Back to the System Design roadmap

Comments