Implementing Vector Clocks for Offline Sync
When two offline replicas edit the same record and reconnect, your sync layer has to answer one question before it can merge anything: did one edit happen after the other, or did they happen concurrently? A Last-Write-Wins comparison cannot tell the difference — it sees two timestamps and picks the bigger one, treating a genuine conflict as if it were an ordinary sequential update. This guide, part of Conflict Resolution Algorithms, implements a vector clock in complete TypeScript so you can distinguish causality from concurrency and only invoke conflict resolution when you actually have a conflict.
The decision in Last-Write-Wins vs CRDT for Offline Notes assumes you can already detect concurrency. A vector clock is the mechanism that detection is built on.
Root cause: physical timestamps do not capture causality
A wall-clock timestamp records when a write happened on one device’s clock, but synchronization needs to know whether one write knew about the other. Those are different questions. If replica A writes at 10:00:05 and replica B writes at 10:00:04, a timestamp says A is newer — yet if both devices were offline and never exchanged data, neither write was aware of the other, and they are concurrent. Worse, device clocks drift; B’s clock could simply be running a second behind. Causality is a partial order (“happened-before”), and you cannot recover a partial order from a single scalar that imposes a total order. A vector clock fixes this by tracking, per replica, how many writes each replica has observed — so it can detect when neither side has seen the other’s latest write.
Step 1 — The VectorClock type
A vector clock is a map from replica ID to a monotonically increasing counter. Each replica owns one entry (its own) and tracks the highest counter it has seen from every other replica.
type ReplicaId = string;
type VectorClock = Record<ReplicaId, number>;
function emptyClock(): VectorClock {
return {};
}
Step 2 — Increment on every local write
Before a replica applies its own write, it bumps its own counter. Nothing else changes; the other replicas’ counters reflect only what this replica has actually observed.
function increment(clock: VectorClock, self: ReplicaId): VectorClock {
return { ...clock, [self]: (clock[self] ?? 0) + 1 };
}
Step 3 — Merge element-wise on receive
When a replica receives an update carrying a remote clock, it merges by taking the element-wise maximum across the union of replica IDs. This records that it now knows about every write either side had seen.
function merge(a: VectorClock, b: VectorClock): VectorClock {
const result: VectorClock = { ...a };
for (const id of Object.keys(b)) {
result[id] = Math.max(result[id] ?? 0, b[id]);
}
return result;
}
Step 4 — Compare to classify the relationship
The comparison is the core. Walk the union of replica IDs and check whether a is less-than-or-equal to b everywhere and vice versa:
a≤bon every entry and they differ →ahappened beforeb.b≤aon every entry and they differ →ahappened afterb.- Equal on every entry → equal.
- Neither dominates → concurrent (a real conflict).
type ClockOrder = 'before' | 'after' | 'equal' | 'concurrent';
function compare(a: VectorClock, b: VectorClock): ClockOrder {
const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
let aLessSomewhere = false;
let bLessSomewhere = false;
for (const id of ids) {
const av = a[id] ?? 0;
const bv = b[id] ?? 0;
if (av < bv) aLessSomewhere = true;
if (bv < av) bLessSomewhere = true;
}
if (!aLessSomewhere && !bLessSomewhere) return 'equal';
if (aLessSomewhere && !bLessSomewhere) return 'before';
if (bLessSomewhere && !aLessSomewhere) return 'after';
return 'concurrent';
}
Step 5 — Detect conflicts before applying
Now the sync layer can make a principled decision. If the incoming update happened after the local state, fast-forward and apply it. If it happened before (it is stale), ignore it. Only when the two are concurrent do you invoke conflict resolution — Last-Write-Wins as a fallback, or a CRDT merge for content that must not lose edits.
interface VersionedRecord<T> {
value: T;
clock: VectorClock;
}
type ApplyResult<T> =
| { kind: 'applied'; record: VersionedRecord<T> }
| { kind: 'ignored-stale' }
| { kind: 'conflict'; local: VersionedRecord<T>; incoming: VersionedRecord<T> };
function receiveUpdate<T>(
local: VersionedRecord<T>,
incoming: VersionedRecord<T>,
): ApplyResult<T> {
const order = compare(incoming.clock, local.clock);
switch (order) {
case 'after':
// Incoming knows everything local knows, plus more — fast-forward.
return { kind: 'applied', record: incoming };
case 'before':
case 'equal':
// Incoming is stale or identical — nothing to do.
return { kind: 'ignored-stale' };
case 'concurrent':
// True conflict — hand off to resolution, keeping the merged clock.
return { kind: 'conflict', local, incoming };
}
}
// Fallback resolution when concurrent: Last-Write-Wins on a per-replica tie,
// but always merge the clocks so the resolved record dominates both inputs.
function resolveConcurrent<T>(
local: VersionedRecord<T>,
incoming: VersionedRecord<T>,
preferIncoming: boolean,
): VersionedRecord<T> {
const winner = preferIncoming ? incoming : local;
return { value: winner.value, clock: merge(local.clock, incoming.clock) };
}
Verification
The following assertions exercise the happened-before relation, a fast-forward, and a concurrent conflict. They are runnable as-is.
function assert(cond: boolean, msg: string): void {
if (!cond) throw new Error(`Assertion failed: ${msg}`);
}
// Causal chain: B's write came after seeing A's write.
let a: VectorClock = increment(emptyClock(), 'A'); // {A:1}
let b: VectorClock = merge(emptyClock(), a); // {A:1}
b = increment(b, 'B'); // {A:1, B:1}
assert(compare(a, b) === 'before', 'A precedes B causally');
assert(compare(b, a) === 'after', 'B follows A causally');
// Concurrent: each replica wrote without seeing the other.
const x: VectorClock = increment(increment(emptyClock(), 'A'), 'A'); // {A:2}
const y: VectorClock = increment(emptyClock(), 'B'); // {B:1}
assert(compare(x, y) === 'concurrent', 'X and Y are concurrent');
// Merge dominates both and equals itself.
const m = merge(x, y); // {A:2, B:1}
assert(compare(m, x) === 'after' && compare(m, y) === 'after', 'merge dominates');
assert(compare(m, m) === 'equal', 'a clock equals itself');
// receiveUpdate routes concurrency to conflict resolution.
const res = receiveUpdate(
{ value: 'local', clock: x },
{ value: 'incoming', clock: y },
);
assert(res.kind === 'conflict', 'concurrent updates raise a conflict');
console.info('All vector-clock assertions passed');
Edge cases and a fallback
- Clock size grows with replicas. A vector clock carries one entry per replica that has ever written, so a system with thousands of short-lived clients (each browser tab, each device) bloats every record. Cap the clock to active replicas and treat absent entries as zero, which the
?? 0defaults above already handle. - Retired replicas. When a device is decommissioned, its entry lingers forever. Prune entries for replicas the server has marked retired, but only once you are certain no in-flight update still references that counter — otherwise pruning can make a stale update look concurrent. Persist the pruning watermark alongside the data, ideally inside the same IndexedDB Transaction Management write.
- Fallback to Last-Write-Wins for concurrent. A vector clock detects conflicts but does not resolve them. For scalar fields, resolve the
concurrentcase with a deterministic Last-Write-Wins tie-break (asresolveConcurrentshows) and always merge the clocks so the result dominates both inputs. For content that must not lose edits, hand the concurrent case to a CRDT instead, as described in Last-Write-Wins vs CRDT for Offline Notes. - Transport reliability. Vector clocks assume updates eventually arrive; ship them over a retrying channel so a dropped update does not strand a replica. The retry mechanics live in Handling Network Flakiness with Exponential Backoff.
Frequently Asked Questions
Why not just compare timestamps instead of using a vector clock?
Because a timestamp imposes a total order on events that only have a partial order. Two offline replicas can write without ever seeing each other; a timestamp will declare one “newer” and silently treat a genuine conflict as a sequential update. A vector clock returns concurrent in exactly that case so you can resolve it deliberately.
Won't the vector clock grow without bound as replicas come and go?
It grows with the number of distinct replicas that have written, not with the number of writes. For browsers with many ephemeral tabs, assign stable per-device replica IDs rather than per-tab, treat missing entries as zero, and prune entries for retired replicas once no in-flight update still references them.
Does a vector clock resolve the conflict for me?
No — it only detects one. When compare returns concurrent, you still choose a resolution: a deterministic Last-Write-Wins tie-break for scalar fields, or a CRDT merge for content that must preserve every edit. Always merge the two clocks into the resolved record so it dominates both inputs.
Related
- Conflict Resolution Algorithms — the parent guide on reconciling divergent replicas.
- Last-Write-Wins vs CRDT for Offline Notes — what to do once a vector clock flags a concurrent edit.
- Handling Network Flakiness with Exponential Backoff — deliver clocked updates reliably over an unstable link.
- IndexedDB Transaction Management — persist a record and its clock atomically.
- Offline Sync Strategies & Background Workflows — the broader synchronization architecture.