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.
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
- Tombstones grow forever. Deletes are never physically removed, so a heavily edited document accumulates dead elements. Periodically prune tombstones during a quiescent moment when every replica has acknowledged the delete, or compact the document on the server and re-seed clients.
- History pruning. CRDT history (the operation log) can dwarf the visible text. Snapshot the converged state and discard operations older than the snapshot once all replicas are past it — coordinate this so you never drop an op a lagging replica still needs.
- Clock skew in LWW. If you keep LWW for simple fields, replace wall-clock time with a hybrid logical clock so a fast device cannot win every conflict. For the full causal-ordering treatment, see Implementing Vector Clocks for Offline Sync.
- Fallback strategy. You do not need one model everywhere. Use LWW for metadata (title, color, pinned) and a CRDT only for the collaboratively edited body. This keeps storage lean where concurrency is impossible and pays the CRDT cost only where it earns its keep. Persist either representation through IndexedDB Transaction Management so a merge and its write commit atomically.
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
- Conflict Resolution Algorithms — the parent guide covering the full reconciliation toolkit.
- Implementing Vector Clocks for Offline Sync — detect causality vs concurrency before you merge.
- Handling Network Flakiness with Exponential Backoff — reliably ship merged state over an unstable connection.
- IndexedDB Transaction Management — commit a merge and its persisted document atomically.
- Offline Sync Strategies & Background Workflows — where conflict resolution fits in the wider sync architecture.