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.

Vector clock compare returns before, after, equal, or concurrent Two replica clocks are compared element-wise; if neither dominates the other across all replica ids the update is concurrent and a conflict is flagged. Clock X {A:2, B:1} Clock Y {A:1, B:2} compare() element-wise before / after equal concurrent flag conflict

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:

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

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