Last-Write-Wins vs CRDT for Offline Notes

You are building a notes app that has to work offline on several devices and reconcile when each one reconnects. Two users edit the same note on a plane and a train; both come back online; one edit silently wins and the other vanishes. That data loss is the defining problem of offline conflict resolution, and the choice between Last-Write-Wins (LWW) and a Conflict-free Replicated Data Type (CRDT) decides whether it happens. This guide sits under Conflict Resolution Algorithms and compares the two approaches with runnable merge code so you can pick deliberately rather than by default.

LWW resolves a conflict by keeping whichever write carries the newest timestamp and discarding the rest. It is trivial to implement and store, but it cannot represent two concurrent edits — one is always thrown away. A CRDT instead defines a merge function that is commutative, associative, and idempotent, so independent replicas always converge to the same state regardless of message order, and concurrent edits are preserved rather than overwritten. The cost is memory and metadata: CRDTs carry per-element identity and often retain history.

Last-Write-Wins discards a concurrent edit while a CRDT merges both Two replicas edit a note concurrently; LWW keeps only the newest timestamp and loses one edit, while a CRDT converges to a merged result containing both edits. Replica A edits t=10 "buy milk" Replica B edits t=11 "call Sam" Last-Write-Wins keeps t=11 "call Sam" drops "buy milk" CRDT merge converges to "buy milk" "call Sam" both preserved

Root cause: a timestamp is not a model of concurrency

The reason LWW loses data is structural, not a bug. Its entire decision is “compare two timestamps, keep the larger.” That comparison assumes the two writes are ordered in time, but offline replicas produce writes that are genuinely concurrent — neither happened-before the other. LWW has no way to express “both are valid,” so it forces a total order onto events that have only a partial order, and the loser is erased. It also depends on wall-clock time, which drifts between devices; a phone with a clock running three minutes fast will win every conflict regardless of who actually edited last. Detecting causality properly requires logical clocks, which is the subject of Implementing Vector Clocks for Offline Sync.

Comparison at a glance

Dimension Last-Write-Wins CRDT
Concurrent edits One is discarded Both preserved and merged
Resolution rule Newest timestamp wins Commutative merge function
Clock dependency Yes — clock skew corrupts results No — uses logical identity
Storage overhead One value + one timestamp Per-element IDs, often history
Implementation cost A few lines Library-grade (Yjs, Automerge)
Convergence guarantee Only if clocks agree Always, order-independent
Best for Single-field, last-edit-wins data Shared text, lists, structured docs

A minimal Last-Write-Wins register

An LWW-register stores a value together with the timestamp of the write that produced it. Merging two registers keeps the one with the higher timestamp; a stable tie-break on replica ID keeps the merge deterministic when timestamps collide.

interface LwwRegister<T> {
  value: T;
  timestamp: number; // logical or wall-clock time of the write
  replicaId: string; // tie-break so merges are deterministic
}

function writeLww<T>(
  current: LwwRegister<T>,
  value: T,
  replicaId: string,
  now = Date.now(),
): LwwRegister<T> {
  // Ensure the new write strictly dominates the current one locally.
  const timestamp = Math.max(now, current.timestamp + 1);
  return { value, timestamp, replicaId };
}

function mergeLww<T>(a: LwwRegister<T>, b: LwwRegister<T>): LwwRegister<T> {
  if (a.timestamp !== b.timestamp) {
    return a.timestamp > b.timestamp ? a : b;
  }
  // Deterministic tie-break: highest replicaId wins on every replica.
  return a.replicaId >= b.replicaId ? a : b;
}

This is enough for fields where the latest value is simply correct — a note’s color label, a pinned boolean, a document title. The merge is commutative and associative because max is, so replicas converge. What it cannot do is keep two different note bodies that were both edited offline.

A conceptual sequence CRDT for the note body

To preserve concurrent edits you model the note body as an ordered set of immutable, uniquely identified characters (or blocks). Each insert gets an ID that is globally unique and totally orderable, deletes are recorded as tombstones rather than removals, and the merge is the union of both sets. Because the merge is set union, it is commutative, associative, and idempotent — the three properties that make a CRDT converge no matter the message order. The example below is a minimal grow-only sequence with tombstones; production apps reach for a library such as Yjs or Automerge, which implement battle-tested sequence CRDTs with compact encodings.

interface SeqElement {
  id: string;        // unique, sortable: `${lamport}:${replicaId}`
  char: string;      // one character (or a block of text)
  deleted: boolean;  // tombstone instead of physical removal
}

interface SequenceCrdt {
  elements: Map<string, SeqElement>;
}

function insertChar(
  doc: SequenceCrdt,
  char: string,
  lamport: number,
  replicaId: string,
): void {
  const id = `${String(lamport).padStart(12, '0')}:${replicaId}`;
  doc.elements.set(id, { id, char, deleted: false });
}

function deleteChar(doc: SequenceCrdt, id: string): void {
  const el = doc.elements.get(id);
  if (el) el.deleted = true; // tombstone — never physically removed here
}

function mergeSequence(a: SequenceCrdt, b: SequenceCrdt): SequenceCrdt {
  const merged = new Map(a.elements);
  for (const [id, el] of b.elements) {
    const existing = merged.get(id);
    // Union of elements; a tombstone on either side wins (delete is permanent).
    merged.set(id, {
      id,
      char: el.char,
      deleted: (existing?.deleted ?? false) || el.deleted,
    });
  }
  return { elements: merged };
}

function render(doc: SequenceCrdt): string {
  return [...doc.elements.values()]
    .filter((el) => !el.deleted)
    .sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))
    .map((el) => el.char)
    .join('');
}

Because every character carries its own identity, two replicas that each type a different word offline both survive the merge — neither is overwritten. That is the entire point a timestamp comparison can never achieve.

Verification

Convergence is the property to assert: merge the two replicas in both orders and confirm they produce the same rendered note. The independence of order is exactly what makes a CRDT safe to sync over an unreliable channel.

function assert(cond: boolean, msg: string): void {
  if (!cond) throw new Error(`Assertion failed: ${msg}`);
}

// LWW: the higher timestamp wins, and the loser's value is gone.
const a0 = writeLww({ value: 'milk', timestamp: 0, replicaId: 'A' }, 'milk', 'A', 10);
const b0 = writeLww({ value: 'sam', timestamp: 0, replicaId: 'B' }, 'sam', 'B', 11);
assert(mergeLww(a0, b0).value === 'sam', 'LWW keeps newest');
assert(mergeLww(a0, b0).value === mergeLww(b0, a0).value, 'LWW is commutative');

// CRDT: both concurrent edits survive, regardless of merge order.
const docA: SequenceCrdt = { elements: new Map() };
const docB: SequenceCrdt = { elements: new Map() };
insertChar(docA, 'X', 1, 'A');
insertChar(docB, 'Y', 1, 'B');
const ab = render(mergeSequence(docA, docB));
const ba = render(mergeSequence(docB, docA));
assert(ab === ba, 'CRDT converges regardless of order');
assert(ab.includes('X') && ab.includes('Y'), 'CRDT preserves both edits');
console.info('All conflict-resolution assertions passed');

Edge cases and a fallback

When LWW is fine vs when you need a CRDT

Choose LWW when edits to a given field are effectively never concurrent, when losing the older of two simultaneous writes is acceptable, or when the value is a single scalar with a clear “latest is correct” semantic. Choose a CRDT when two people can meaningfully edit the same content at the same time, when silently dropping an edit is a data-loss bug rather than a tolerable outcome, or when you need guaranteed convergence over an unreliable, out-of-order sync channel. Most real notes apps end up mixing both, and reconcile the merged result through the broader retry machinery in Handling Network Flakiness with Exponential Backoff.

Frequently Asked Questions

Can I just use Last-Write-Wins everywhere to keep it simple?

Only if you can tolerate silently discarding the older of two concurrent edits. LWW is genuinely fine for scalar fields like a note’s title or color where “latest wins” is the correct semantic. It is the wrong choice for a shared body two people edit offline, because one person’s paragraph will disappear with no error.

Do I have to write my own CRDT?

No, and you generally should not for text. The sequence CRDT shown here is intentionally minimal to illustrate the merge. Production apps use a mature library such as Yjs or Automerge, which provide efficient sequence types, compact binary encoding, and tombstone management that a hand-rolled Map does not.

How do I stop CRDT tombstones from growing without bound?

Prune them during quiescent periods once every replica has acknowledged the deletes, or snapshot the converged document on the server and re-seed clients from the snapshot. Never drop a tombstone or operation that a lagging offline replica might still need to converge correctly.

Related