Deep Dive · Issue 18
The stale-state overwrite: why a one-time freshness check is a time bomb in distributed coordination
A lock you hold is not a lock you own. If your process pauses, the lease expires, and a freshness check can't save you.

The problem
Your lock expired while you were asleep

Martin Kleppmann diagnosed this in 2016: a client acquires a lock, reads shared storage, modifies it, writes back. If a GC stop-the-world pause exceeds the lease TTL, the client wakes up after the lock expired and another client re-acquired it, then overwrites newer state. HBase had this bug.
The failure is inherent to lease-based locking, not a bug in any implementation. A one-time freshness check ("is the lock still mine?") does not help because the pause can occur between the check and the write. CWE-367 names this: a time-of-check to time-of-use race, where the gap between checking and acting is the window for corruption.
The mechanism
Fencing tokens: the storage layer decides

The fix is a fencing token: a monotonically increasing number assigned by the lock service on each acquisition. The client includes the token with every write in the critical section. The storage layer tracks the highest token it has seen and rejects any write with a lower number.
Fencing token — A monotonically increasing number assigned by the lock service on each lock acquisition, included with every storage write, and enforced by the storage server to reject stale writes.
Three components are required: the lock service generates strictly increasing tokens, the client propagates the token with every operation, and the storage layer enforces the ordering. Drop any one and the protection vanishes. The lock service generates the token; the storage server enforces it. Both halves are required.
The numbers
Which lock services can actually fence

Redlock: clocks are the fatal flaw
Redlock distributes locks across 5 independent Redis instances and requires a quorum of 3. Its correctness depends on clocks advancing at the same rate. If one instance's clock drifts ahead from NTP or VM migration, it expires a lock early while others still consider it valid, and two clients hold the lock simultaneously.
ZooKeeper: natural tokens, but no free lunch
ZooKeeper avoids clock dependency with session-based expiration instead of TTL timers. A sequential ephemeral node's number (or zxid) is a natural monotonic fencing token, globally ordered across the cluster. But "built-in fencing" is a myth: the storage layer must still enforce tokens. If the database does not reject stale ones, you are no safer than with Redis.
The tradeoff
Atomic check-and-act, or architecture

A conditional UPDATE sidesteps the fencing problem entirely by making the check and the write atomic. In a reproducible lab with 20 concurrent requests and a 1-voucher limit, no locking leaked 16 of 20; a naive non-atomic Redis lock leaked 9 of 20 over HTTP/2. A conditional UPDATE held at exactly 1 of 20.
UPDATE daily_quota
SET granted = granted + 1
WHERE member_id = :member_id
AND granted < :limit;CWE-367 classifies this as a TOCTOU race, and the conditional UPDATE closes the gap because in READ COMMITTED (Postgres's default), the UPDATE re-reads the latest committed row at execution time and re-evaluates its WHERE clause in one atomic step. But the lab proves DB-internal atomicity only. When side effects live outside the database (external payment calls, coupon cancellations, point grants), you need idempotency keys, the outbox pattern, or compensating transactions.
Closing
The check is never the last word
Why it matters
The stale-state overwrite is the default failure mode of any system that checks freshness once and then acts later. GC pauses, network delays, and clock drift are not exceptional in distributed systems; they are the environment. The systems that survive are the ones where the storage layer, not the client, has the final say on whether a write is stale, and where the lock service and the storage server are treated as two halves of one mechanism.
The short version
- A one-time freshness check cannot prevent stale writes because the process can pause between the check and the write.
- **Fencing tokens** fix this: the lock service generates a monotonic token, and the storage layer rejects any write with a lower token than it has already seen.
- **Redlock** depends on bounded clock drift and cannot generate fencing tokens; **ZooKeeper's** sequential nodes can, but only if the storage layer enforces them.
- A **conditional UPDATE** makes check and write atomic inside the database, but external side effects need idempotency keys, outbox, or compensating transactions.
Sources
- 1.The Fencing Gap: Why Your Distributed Lock Isn't Safe ...
- 2.How Distributed Systems Avoid Race Conditions using Pessimistic Locking?
- 3.Race Condition (TOCTOU) Remediation: What Actually Worked in a Real Stack | After the Code
- 4.CWE - CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition (4.20)
- 5.How to do distributed locking — Martin Kleppmann’s blog
- 12.Implementing Distributed Locks Correctly