The Hard Problem Offline-First Apps Eventually Hit
Once two devices can independently edit the same data while offline, you need a strategy for reconciling conflicting changes when they sync back up. Simple approaches break down fast; this is where Conflict-free Replicated Data Types (CRDTs) earn their complexity.
Why “Last Write Wins” Isn’t Enough
Last-write-wins silently discards one user’s changes, which is fine for a UI preference but genuinely unacceptable for something like a shared shopping list or collaborative document — a user’s real work just disappears without any indication it happened.
A Simple CRDT: Grow-Only Counter
class GCounter {
constructor(nodeId) {
this.nodeId = nodeId;
this.counts = {};
}
increment() {
this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;
}
value() {
return Object.values(this.counts).reduce((a, b) => a + b, 0);
}
merge(other) {
for (const [node, count] of Object.entries(other.counts)) {
this.counts[node] = Math.max(this.counts[node] || 0, count);
}
}
}
Two devices can independently increment the same counter offline, and merging simply takes the max per-node count — no conflicts possible by construction.
A Practical CRDT: Last-Write-Wins Register with Timestamps
class LWWRegister {
constructor(value = null, timestamp = 0) {
this.value = value;
this.timestamp = timestamp;
}
set(value, timestamp) {
if (timestamp > this.timestamp) {
this.value = value;
this.timestamp = timestamp;
}
}
merge(other) {
if (other.timestamp > this.timestamp) {
this.value = other.value;
this.timestamp = other.timestamp;
}
}
}
Using a CRDT Library Instead of Rolling Your Own
For anything beyond simple counters, use a battle-tested library like Yjs or Automerge rather than implementing CRDTs from scratch:
import * as Y from 'yjs';
const doc = new Y.Doc();
const list = doc.getArray('shoppingList');
list.push(['milk', 'eggs']);
// Sync state between devices
const state = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(remoteDoc, state);
When CRDTs Are Overkill
Not every offline-first feature needs CRDTs. For data owned by a single user that’s never edited on two devices simultaneously in practice (personal notes, settings), a simple versioned last-write-wins with a manual conflict prompt is often sufficient and far simpler to reason about.
Handling Merge Conflicts the User Should See
function detectConflict(localVersion, remoteVersion, baseVersion) {
const localChanged = localVersion !== baseVersion;
const remoteChanged = remoteVersion !== baseVersion;
return localChanged && remoteChanged && localVersion !== remoteVersion;
}
When true conflicts exist and automatic resolution isn’t safe (financial data, for example), surface both versions to the user rather than silently picking one.
Conclusion
CRDTs solve the specific problem of merging concurrent offline edits without data loss or manual conflict resolution — but reach for an established library rather than implementing the merge semantics yourself, since subtle bugs in custom CRDT logic are hard to catch in testing and expensive to fix after real user data is affected.