Coordination services and distributed locks
"Only one server may run the nightly billing job." "If the leader dies, another must take over within seconds, and never two at once." These sound like one-line requirements, and they're among the hardest to get right, because servers pause, clocks drift and networks split. Coordination services exist so you don't build consensus yourself. This guide explains how they work, the products, and when a simpler lock is enough, with a chooser and code you can run.
- Sam "Only one server may run the billing job." How hard can that be?
- Priya Harder than it sounds. Servers pause, clocks drift and networks split, so two servers can each believe they're in charge. Coordination services exist so you don't build consensus yourself.
- Sam What do they offer?
- Priya A small cluster of three or five nodes agrees on every change. On top of that: a consistent key-value store, leases and ephemeral keys that vanish when their owner stops renewing, watches that notify on change, and compare-and-set with revision numbers.
- Sam How does leader election work?
- Priya Both candidates try to create the leader key, tied to a lease, only if it doesn't exist. One wins and renews the lease every few seconds. If it stops renewing, the lease expires and the key is deleted. The other candidate's watch fires, it takes over, and its token is higher, so the old leader's actions are rejected if it wakes up.
- Sam What are the options?
- Priya ZooKeeper, with ephemeral sequential nodes; Kafka dropped it for its own KRaft in version four. etcd, Raft-based, which stores all of Kubernetes' state. Consul, for service discovery, under the Business Source License since twenty twenty-three. Kubernetes Lease objects for leader election inside a cluster. Locks built on DynamoDB conditional writes or other strongly consistent databases. And Redis locks, fine for avoiding duplicate work but not for correctness.
- Sam How do I choose?
- Priya First ask whether you need a lock at all. If two holders only waste work, a Redis lock with an expiry is fine. If they would corrupt data, use a consensus-backed store and fencing tokens. On Kubernetes, use Leases.
- Priya Your turn. A lock holder pauses longer than its lease. What stops its stale write?
- Priya A fencing token checked by the storage. Any timeout can be exceeded.
- Sam And the code?
- Priya A client takes a lock, pauses for fifteen seconds, and wakes up after another client has taken over. Without fencing its stale write wins; with fencing it's rejected.
- Sam Common mistakes?
- Priya Locks without fencing. Redis locks for correctness. Storing big data in the coordination service. An even number of nodes. And leases shorter than normal pauses.
1. What coordination services do
A small cluster (three or five nodes) agrees on every change with a consensus protocol (Module 21), so it stays correct when a minority fails. On top of that agreement it offers a few building blocks:
| Building block | What it gives you | Used for |
|---|---|---|
| Consistent key-value store | Small values everyone reads the same way | Configuration, cluster membership, feature switches |
| Leases and ephemeral keys | A key that disappears when its owner stops renewing it | Detecting dead servers; locks that free themselves |
| Watches | A notification when a key changes | Reacting at once to a new leader or new config |
| Compare-and-set and revisions | Changes that succeed only if nothing changed since you read | Leader election, fencing tokens |
2. How leader election works
3. The products
Apache ZooKeeper
A tree of small nodes (znodes) replicated with its own consensus protocol, ZAB. Ephemeral znodes vanish when the client's session ends; sequential znodes get increasing numbers, the basis of locks and elections; watches notify on change. Long used by Hadoop, HBase and Kafka; Kafka 4.0 (2025) removed ZooKeeper in favour of its built-in KRaft.
Best for: systems that already depend on it, and the Java ecosystem.
etcd
A key-value store replicated with Raft, with leases, watches, transactions and a revision number on every change. It stores all of Kubernetes' state.
Best for: cloud-native coordination, and anything already on Kubernetes.
HashiCorp Consul
Raft-replicated service catalogue, health checks, a key-value store with sessions for locks, and a service mesh. Licensed under the Business Source License since August 2023, so not OSI open source.
Best for: service discovery across VMs and data centres.
Kubernetes Leases
A Lease object, updated through the API server (and so stored in etcd), that client libraries use for leader election among replicas of a controller or service.
Best for: "one active replica" inside a Kubernetes cluster, with no extra system.
DynamoDB conditional writes
A lock is an item written only if absent or expired, with a lease time and a version number; AWS publishes a lock client built this way. Spanner, Firestore or PostgreSQL (advisory locks, or a row with SELECT … FOR UPDATE) work similarly.
Best for: locks when you already have a strongly consistent database.
Redis and Valkey locks
SET key value NX PX 30000 takes a lock with an expiry. Fine for avoiding duplicate work, but a failover can lose the lock, and the multi-node Redlock algorithm is debated for correctness, so don't use it where a double holder corrupts data.
Best for: best-effort locks (deduplicating jobs, rate-limiting work).
4. Differences at a glance
| Option | Agreement | Lock frees itself by | Fencing token | Runs as |
|---|---|---|---|---|
| ZooKeeper | ZAB consensus | Session expiry (ephemeral znode) | Sequential znode number, zxid | 3 or 5 servers |
| etcd | Raft | Lease expiry | Key revision | 3 or 5 servers; inside Kubernetes |
| Consul | Raft | Session invalidation | Lock index | Server cluster plus agents |
| Kubernetes Lease | etcd underneath | Lease duration | Resource version, holder transitions | Any Kubernetes cluster |
| DynamoDB lock | Strongly consistent conditional writes | Lease time on the item | A version you increment | Managed |
| Redis lock | None (single primary, async replicas) | Key expiry | Not built in | Your cache |
5. When to choose which, and why
- Do you need a lock at all? An idempotent operation, a unique constraint, or a single queue consumer often removes the need.
- Only efficiency at stake (two servers doing the same work is wasteful, not harmful)? A Redis lock with an expiry is fine.
- Correctness at stake (two holders would corrupt data)? Use a consensus-backed store or a strongly consistent database, and fencing tokens checked by the resource you protect.
- On Kubernetes? Use Lease-based leader election from the client libraries; it's already there.
- Building infrastructure that many services coordinate through (membership, config, discovery)? etcd, ZooKeeper or Consul.
Try it: choose for your requirements
6. Why locks need fencing tokens, in code
Client A takes a lock with a 10-second lease, then pauses for 15 seconds (a garbage-collection pause, a stalled VM). Its lease expires and B takes the lock. When A wakes, it still believes it holds the lock and writes. Without fencing, A's stale write overwrites B's. With fencing, each grant carries a higher token, and the storage rejects any write with a token lower than one it has already seen. The program was run in all three languages; each prints the output below it.
class LockService:
"""A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token."""
def __init__(self, lease):
self.lease, self.holder, self.expires, self.token = lease, None, 0, 32
def acquire(self, client, now):
if self.holder and now < self.expires:
return None # someone else holds a live lease
self.token += 1
self.holder, self.expires = client, now + self.lease
return self.token
class AccountStore:
def __init__(self, fenced):
self.fenced, self.value, self.highest = fenced, None, 0
def write(self, client, token, value):
if self.fenced and token < self.highest:
return f"{client} write rejected (token {token} < {self.highest})"
self.highest = max(self.highest, token)
self.value = value
return f"{client} wrote {value!r} with token {token}"
for fenced in (False, True):
lock, store = LockService(lease=10), AccountStore(fenced)
print("with fencing tokens:" if fenced else "without fencing:")
a = lock.acquire("A", now=0)
print(f" t=0 A gets the lock, token {a}; then pauses for 15 s (garbage collection)")
b = lock.acquire("B", now=12)
print(f" t=12 A's lease has expired; B gets the lock, token {b}")
print(" t=13 ", store.write("B", b, "balance=90"))
print(" t=15 ", store.write("A", a, "balance=100"), "<- A wakes, still thinks it holds the lock")
print(f" final value: {store.value!r}")
// A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token.
class LockService {
holder: string | null = null;
expires = 0;
token = 32;
constructor(readonly lease: number) {}
acquire(client: string, now: number): number | null {
if (this.holder && now < this.expires) return null; // someone else holds a live lease
this.token++;
this.holder = client;
this.expires = now + this.lease;
return this.token;
}
}
class AccountStore {
value: string | null = null;
highest = 0;
constructor(readonly fenced: boolean) {}
write(client: string, token: number, value: string): string {
if (this.fenced && token < this.highest) return `${client} write rejected (token ${token} < ${this.highest})`;
this.highest = Math.max(this.highest, token);
this.value = value;
return `${client} wrote '${value}' with token ${token}`;
}
}
for (const fenced of [false, true]) {
const lock = new LockService(10), store = new AccountStore(fenced);
console.log(fenced ? "with fencing tokens:" : "without fencing:");
const a = lock.acquire("A", 0)!;
console.log(` t=0 A gets the lock, token ${a}; then pauses for 15 s (garbage collection)`);
const b = lock.acquire("B", 12)!;
console.log(` t=12 A's lease has expired; B gets the lock, token ${b}`);
console.log(" t=13 ", store.write("B", b, "balance=90"));
console.log(" t=15 ", store.write("A", a, "balance=100"), "<- A wakes, still thinks it holds the lock");
console.log(` final value: '${store.value}'`);
}public class Main {
// A lease-based lock, like one built on etcd, ZooKeeper or Consul. Each grant gets a higher token.
static class LockService {
final int lease;
String holder = null;
int expires = 0, token = 32;
LockService(int lease) { this.lease = lease; }
Integer acquire(String client, int now) {
if (holder != null && now < expires) return null; // someone else holds a live lease
token++;
holder = client;
expires = now + lease;
return token;
}
}
static class AccountStore {
final boolean fenced;
String value = null;
int highest = 0;
AccountStore(boolean fenced) { this.fenced = fenced; }
String write(String client, int token, String value) {
if (fenced && token < highest) return client + " write rejected (token " + token + " < " + highest + ")";
highest = Math.max(highest, token);
this.value = value;
return client + " wrote '" + value + "' with token " + token;
}
}
public static void main(String[] args) {
for (boolean fenced : new boolean[] {false, true}) {
LockService lock = new LockService(10);
AccountStore store = new AccountStore(fenced);
System.out.println(fenced ? "with fencing tokens:" : "without fencing:");
int a = lock.acquire("A", 0);
System.out.println(" t=0 A gets the lock, token " + a + "; then pauses for 15 s (garbage collection)");
int b = lock.acquire("B", 12);
System.out.println(" t=12 A's lease has expired; B gets the lock, token " + b);
System.out.println(" t=13 " + store.write("B", b, "balance=90"));
System.out.println(" t=15 " + store.write("A", a, "balance=100") + " <- A wakes, still thinks it holds the lock");
System.out.println(" final value: '" + store.value + "'");
}
}
}without fencing:
t=0 A gets the lock, token 33; then pauses for 15 s (garbage collection)
t=12 A's lease has expired; B gets the lock, token 34
t=13 B wrote 'balance=90' with token 34
t=15 A wrote 'balance=100' with token 33 <- A wakes, still thinks it holds the lock
final value: 'balance=100'
with fencing tokens:
t=0 A gets the lock, token 33; then pauses for 15 s (garbage collection)
t=12 A's lease has expired; B gets the lock, token 34
t=13 B wrote 'balance=90' with token 34
t=15 A write rejected (token 33 < 34) <- A wakes, still thinks it holds the lock
final value: 'balance=90'
7. Say it in the interview
- "How do you make sure only one scheduler runs?" Leader election through a lease in etcd (a Kubernetes Lease on Kubernetes): the leader renews it every few seconds, and a standby takes over when it expires. Every action the leader takes carries the lease's fencing token, so a paused old leader can't act.
- "Can you use Redis for the lock?" For avoiding duplicate work, yes, with an expiry. Not where two holders would corrupt data, because a failover can lose the lock; there I'd use a consensus-backed store and fencing.
- "Why not just a timeout?" A process can pause longer than any timeout and wake up believing it still holds the lock; only the protected resource checking a token stops it.
8. Common mistakes
9. Check yourself
A lock holder pauses for longer than its lease. What prevents its stale write?
Why do coordination clusters use three or five nodes, not four?
A cron job on three replicas should run once; running twice just wastes a minute. A reasonable lock?
Inside Kubernetes, how do controllers usually elect a leader?
10. Sources and further reading
Reviewed on 4 November 2026.
| Source | Used for |
|---|---|
| etcd documentation | Leases, watches, revisions |
| ZooKeeper recipes | Locks and leader election with ephemeral sequential znodes |
| Kubernetes: Leases | Leader election inside clusters |
| Martin Kleppmann: How to do distributed locking | Fencing tokens and the Redlock debate |
| Module 21: Consensus and coordination | Raft, quorums and leases |