Skip to content

Implementing Fencing Tokens for Distributed Transactions

Core Concept LearningAugust 3, 20269 min read

A distributed lock feels like it should be enough: acquire it, do your write, release it, and only one client at a time gets to touch the resource. It works right up until a client pauses — a garbage-collection stop-the-world, a slow disk write, a network partition — for longer than the lock's lease. The lock service, having no way to know the client is still alive, expires the lease and hands the lock to someone else. When the paused client wakes up, it has no idea time passed; it finishes its write believing it still holds the lock, and now two clients have written to the same resource believing each had exclusive access.

This guide builds one concrete example: two workers competing for a lock on an inventory record, where one worker stalls due to a garbage-collection pause right after acquiring the lock. You'll see why the lock alone doesn't prevent a stale write, add a monotonically increasing fencing token to close the gap, and trace the one place that check actually has to live — not in the lock service, but in the resource being protected. For the lock-service comparison this builds on, see distributed locking with Redis, ZooKeeper, and etcd and when to use Redis Redlock vs. etcd vs. ZooKeeper.

A fencing token turns a lock into an ordering guarantee
A fencing token turns a lock into an ordering guarantee

Why a lock alone isn't exclusive

A distributed lock is a lease with an expiry, not an unbreakable promise. The lock service (Redis, ZooKeeper, etcd) grants a lease for some duration, and the client is expected to renew it before it expires or finish its work within the window. The failure mode that breaks the exclusivity guarantee is any pause the lock service can't observe: a JVM garbage-collection stop-the-world pause, a suspended VM, a slow disk fsync, or a long network partition. From the lock service's point of view, a client that stops sending heartbeats looks identical to a dead client — so it does the only safe thing and expires the lease, handing the lock to the next requester.

The dangerous part is what happens next: the original client eventually resumes, has no idea time passed while it was paused, and proceeds to write to the resource as if it still held the lock — because, from its own perspective, it never released anything. Two writers, both believing they have exclusive access, is exactly the failure a lock was supposed to prevent.

Quick reference

  • Lock leases are time-bounded by design — a lock with no expiry risks permanent deadlock if the holder crashes without releasing it.
  • A GC pause of several seconds is common under memory pressure in managed-runtime services (JVM, .NET, Node); design lock leases assuming pauses happen, not as a rare edge case.
  • Network partitions produce the same effect as a process pause from the lock service's point of view — it cannot distinguish "slow" from "dead."
  • The lock service correctly followed its contract (expire an unrenewed lease) — the bug is in assuming the lock alone protects the resource, not in the lock service's behavior.

Remember this

A distributed lock's exclusivity guarantee only holds between the lock service and clients that never pause longer than the lease — any real pause (GC, disk, network) breaks that assumption, which is a property of distributed systems, not a bug in any specific lock implementation.

The fencing token: an ordering guarantee, not a lock

A fencing token is a number issued by the lock service every time it grants the lock, guaranteed to increase monotonically across every grant — 33, then 34, then 35, regardless of which client receives each one. The client attaches this token to every write it sends to the protected resource. The insight that makes this work: instead of trying to prevent the paused client from writing (which the lock service cannot reliably do), you make the resource reject any write whose token is not higher than the highest one it has already seen.

In the inventory example, Client A acquires the lock and gets token 33, then pauses. The lock expires; Client B acquires it and gets token 34, writes successfully, and the storage records 34 as the last-seen token. Client A resumes, unaware time passed, and sends its write tagged with token 33. The storage layer compares 33 against its last-seen value of 34, sees that 33 is not greater, and rejects the write. Client A's stale write never lands — not because anyone stopped Client A from trying, but because the resource itself enforced the ordering.

A GC pause creates two lock holders — the token resolves it
A GC pause creates two lock holders — the token resolves it

Quick reference

  • The token must be monotonically increasing across the entire lock's lifetime, not per-client — a simple incrementing counter on the lock service works; a random ID does not, because it can't be compared.
  • The check belongs in the resource/storage layer, not in the lock client — a paused client cannot be trusted to check anything correctly once it has already lost track of time.
  • Rejecting a stale write should be loud (an explicit error), not a silent no-op — silent rejection hides the fact that a real conflict happened and needs investigating.
  • This pattern composes with optimistic concurrency control (a version column) — the fencing token and a row version can be the same mechanism if the storage layer already tracks a monotonic version.
Lock without a fencing token
1async function updateInventory(lock: DistributedLock, sku: string, delta: number) {2  const acquired = await lock.acquire(sku, { ttlMs: 10_000 });3  if (!acquired) throw new Error("lock busy");4 5  // If this call pauses here for >10s (GC, disk stall), the lease expires6  // and another client can acquire the same lock while this one "still7  // thinks" it holds it.8  await inventoryStore.applyDelta(sku, delta);9  await lock.release(sku);10}
Lock issuing a fencing token, enforced at the storage layer
1async function updateInventory(lock: DistributedLock, sku: string, delta: number) {2  const { acquired, fencingToken } = await lock.acquire(sku, { ttlMs: 10_000 });3  if (!acquired) throw new Error("lock busy");4 5  // The storage layer — not the lock — enforces monotonicity.6  await inventoryStore.applyDelta(sku, delta, { fencingToken });7  await lock.release(sku);8}9 10// Inside inventoryStore.applyDelta:11async function applyDelta(sku: string, delta: number, opts: { fencingToken: number }) {12  const lastSeen = await getLastFencingToken(sku);13  if (opts.fencingToken <= lastSeen) {14    // Expected: Client A's stale token (33) is rejected once Client B's15    // write (token 34) has already landed.16    throw new Error(`stale_fencing_token: ${opts.fencingToken} <= ${lastSeen}`);17  }18  await db.transaction(async (tx) => {19    await tx.updateStock(sku, delta);20    await tx.setLastFencingToken(sku, opts.fencingToken);21  });22}23// Break it: skip the fencingToken check in applyDelta and simulate a24// paused Client A writing after Client B — expected: the stale write25// lands and corrupts the stock count.

Remember this

A fencing token does not stop a paused client from trying to write — it makes the resource itself refuse any write that arrives out of order, which is the only place the guarantee can actually be enforced.

The one place this check must live

The single most common mistake in a fencing-token implementation is putting the monotonicity check in the wrong place — inside the lock client's own logic, or in an application-layer guard that runs before the actual write. Neither is safe, because both can be skipped by the exact failure mode fencing tokens exist to handle: a client that is paused and has lost track of correct state. The check has meaning only if it happens at the boundary the paused client cannot bypass — the storage system that persists the write.

This is also why fencing tokens require the protected resource to support the check at all. If the resource is a plain key-value store with no way to attach a conditional write (compare-and-swap on a version field), you cannot retrofit fencing token safety onto it without adding that capability first. Systems like ZooKeeper (zxid), etcd (revision numbers), and a database row's version column all provide a natural place to store and compare the last-seen token.

Quick reference

  • Verify the storage layer supports an atomic compare-and-set or equivalent transactional check before promising fencing-token safety on top of it.
  • A cache (Redis without transactions wrapping the check-and-write) is often the wrong place to enforce this — prefer the system of record that already has ACID guarantees.
  • etcd's revision number and ZooKeeper's zxid are both usable directly as fencing tokens if your resource already talks to one of those systems.
  • Document which layer owns the fencing check in your architecture — a reviewer six months later needs to find it without re-deriving the whole design.

Remember this

A fencing token check placed anywhere except the resource's own write path is a check a paused client can simply not reach — the guarantee only exists at the boundary the client cannot bypass by being asleep.

When you need this, and when a lease is enough

Not every lock needs a fencing token. If the operation behind the lock is naturally idempotent (writing the same value twice is harmless) or if a duplicate write is cheap to detect and reconcile later, a plain lease-based lock without fencing is simpler and sufficient. Reach for fencing tokens specifically when the protected operation is a non-idempotent mutation with real consequences from double-application — inventory deltas, financial transfers, or any "apply this change exactly once" write where a duplicate corrupts state rather than merely repeating a no-op.

The realistic failure to keep in mind in production: a fencing token scheme that works in every test because tests don't reproduce multi-second GC pauses. Load-test with an artificial pause injected (a Thread.sleep or equivalent right after lock acquisition, before the write) to confirm the storage layer actually rejects the resulting stale write — a fencing token implementation that has never seen a real stale write in testing is unverified, not safe.

The storage-side check that fencing tokens require
The storage-side check that fencing tokens require

Quick reference

  • Skip fencing tokens for idempotent operations (SET x = final_value) where a duplicate write causes no harm.
  • Require fencing tokens for non-idempotent deltas (x += delta) where applying the same operation twice changes the outcome.
  • Explicitly test the stale-write path with an injected pause — a scheme untested against a real stale write is a design on paper, not a verified guarantee.
  • Combine fencing tokens with monitoring on rejected-write counts — a sudden spike in stale-token rejections usually signals a lock lease that's too short for your actual workload latency.

Remember this

Fencing tokens are worth the extra moving part specifically for non-idempotent operations where a duplicate write corrupts state — for idempotent writes, a plain lease-based lock is simpler and just as safe.

Key takeaway

Build the inventory example: a lock service that issues a monotonically increasing token on every grant, and a storage layer that rejects any write whose token is not greater than the last one it accepted. Verify success with two workers competing for the same SKU under normal timing — only the winning worker's delta lands.

Then break it deliberately: inject an artificial pause (sleep for longer than the lock's TTL) in one worker right after it acquires the lock but before it writes, let a second worker acquire the lock and write successfully, then let the first worker resume and attempt its write with the now-stale token. Pass criterion: the storage layer rejects the stale write with an explicit error, the second worker's write is the one that persists, and the rejection is visible in your logs or metrics — not silently dropped.

Share:

Related Articles

Building fault-tolerant distributed databases requires keeping multiple server nodes synchronized on a sequence of state

Read

In high-concurrency microservices architectures, preventing race conditions when multiple stateless worker instances acc

Read

System design interviews evaluate a candidate's ability to architect scalable, resilient, and cost-effective distributed

Read

Keep learning

Follow a structured path or browse all courses to go deeper.