18 // journal

Offline sync conflicts: last-write-wins, CRDTs, and when each loses data

engineering · 2026-11 · 6 min read · engineering · sync · offline

journal/offline-sync-conflicts
Offline sync conflicts: last-write-wins, CRDTs, and when each loses data

fig.01 · same conflict, two merge rules: lww keeps one write, a counter crdt keeps both

A phone and a laptop both edit the same record while one of them is in airplane mode. They reconnect, they sync, and one device's work disappears. Not because of a bug: because someone, somewhere in the stack, picked a merge rule that discards writes. Every offline-capable app has a conflict policy whether it declares one or not.

The constraint

Two things have to hold when replicas write independently and reconcile later. First, every replica has to end at the same state without a central authority deciding, since "offline" is the whole point. Second, the merged state has to mean something: converging on the wrong answer is still wrong.

The hard part is that these pull against each other. Keeping every write is mathematically easy; keeping every write meaningful is a domain problem no algorithm solves for you.

The options

Last-write-wins. Stamp every write with a timestamp, keep the biggest one. Simple, converges instantly, and it is the default in more systems than you'd expect. The problem is in the name: only one write is "last". Shapiro's foundational CRDT paper builds the loss into the spec: the LWW-Register's merge "selects the value with the highest timestamp," and every other concurrent assignment is gone. Riak's conflict resolution docs are blunter: LWW "will necessarily drop some writes in the case of concurrent updates," and timestamps "are not a reliable resolution mechanism in distributed systems," because device clocks lie.

Version vectors with sibling detection. Instead of guessing which write is last, track causality: each version carries the history it saw. When two versions both contain writes the other missed, the system stores both as "siblings" and hands them to the application to merge. This is the Dynamo and Riak model. It detects conflicts honestly and loses nothing, but it answers "we don't know" a lot, and somebody's code has to merge those siblings.

Operational transform. The technique behind server-based collaborative editors: rewrite each operation against the concurrent ones so intents survive. It owns collaborative text on the web, with a catch the Yjs docs state plainly: "OT approaches that support shared editing without a central source of truth (a central server) require too much bookkeeping to be viable in practice." If your architecture allows a central server, fine. Offline-first doesn't.

CRDTs. Data types whose merge is mathematically guaranteed to converge: replicas that applied the same updates in any order reach equivalent state, with no coordination. That guarantee, Strong Eventual Consistency, is what makes the local-first software essay's third ideal, "the network is optional," buildable. The catch moves the problem rather than removing it: CRDTs guarantee convergence, not correctness.

The mechanism

The difference is visible in a few lines. Run this in Node:

// two devices write the same field while offline, then sync
const writes = [
  { ts: 1731340802, value: "balance: 4.20", from: "phone" },
  { ts: 1731340810, value: "balance: 9.00", from: "laptop" },
];
const lww = writes.reduce((a, b) => (b.ts > a.ts ? b : a));
console.log("lww keeps:", lww.value);
// lww keeps: balance: 9.00  (the phone write is discarded)

// grow-only counter: each replica tallies itself, merge = per-replica max
const replicaA = { A: 3, B: 0 };   // phone incremented 3x offline
const replicaB = { A: 0, B: 2 };   // laptop incremented 2x offline
const merged = {
  A: Math.max(replicaA.A, replicaB.A),
  B: Math.max(replicaA.B, replicaB.B),
};
console.log("g-counter value:", merged.A + merged.B);
// g-counter value: 5  (every increment survives)

LWW throws one replica's state away. The counter loses nothing because its merge function (element-wise max, then sum) was designed so concurrent updates commute. That is the entire CRDT trick: pick a data type whose merge math can't create ambiguity in the first place.

Where it bites

CRDTs lose data too, just later and with better manners.

Same-key writes still pick one winner. When two users update the same property concurrently, Automerge's docs say it "picks one of the concurrently written values as the 'winner'"; the losers survive only inside a conflicts object you have to query for. And Automerge's "LWW" is not wall-clock LWW: the winner is chosen by internal operation ID, deterministic but arbitrary, so the value you display may not be the one a human wrote last. Yjs resolves it the same way at the data-structure level: "the last inserted entry for each key is used, and all other duplicates for each key are flagged as deleted."

Counters are a different type for a reason. Automerge's own counter docs give the failure: two users each read 3 and increment, both write 4, and a plain number merges to 4 where the answer should be 5. If you model money or inventory as a register instead of a counter CRDT, you have rebuilt LWW inside your CRDT. (If the field is a balance, also see store money as integers: the same record can be wrong in two different ways.)

Convergence is not correctness. Two offline devices can each sell the last seat; a counter CRDT will faithfully merge both sales to minus one. Every merge strategy on this list converges; none of them can enforce seats >= 0, because that is a business invariant, not a data type. It has to be checked before the write is accepted, or compensated after.

The merge tax is metadata. Yjs's internals document that deleted items are flagged rather than removed; even with garbage collection the item's skeleton stays behind for conflict resolution. Yjs's author puts it plainly: each user interaction "creates more metadata that the CRDT needs to retain in order to ensure conflict resolution." Long-lived collaborative documents get heavier, not just longer.

The numbers

  • Cassandra resolves every row with "a Last-Write-Wins Element-Set conflict-free replicated data type," timestamps on every mutation, deletes included, and the docs warn that correctness "depend[s] on these clocks," meaning NTP discipline is part of your consistency model.
  • Riak's recommendation: LWW is useful and safe only "if you are certain that there will be no concurrent updates." If concurrent updates are even possible, they advise against it as a general strategy.
  • Yjs implements YATA (a 2016 peer-to-peer shared-editing algorithm) and ships six shared types: Y.Map, Y.Array, Y.Text, plus Y.XmlFragment, Y.XmlElement, Y.XmlText. Automerge ships Map, Array, Text, Counter, and scalars, and preserves full change history for time travel.

Our position

Pick LWW only for fields where a later write fully supersedes an earlier one and losing a concurrent write is acceptable: UI preferences, cursor positions, "last opened tab." If a record matters, LWW is not a strategy, it is a quiet data-loss feature.

Pick a CRDT library (Automerge or Yjs) when offline or multi-device editing is core to the product, and reach for version-vector-style sibling detection when conflicts are rare enough that a human can resolve them. Whichever you pick, domain invariants stay your job: merge decides what the replicas agree on, never whether they should.

The takeaway

STRATEGY
lww: timestamp winner, losers discarded. overwrite-only
fields: settings, cursors, last-viewed state

version vectors: detect divergence, keep siblings,

app merges. conflicts rare + human-resolvable

ot: text collab with a central server only

crdt: auto-merge offline. lists, text, counters,

shared doc structures
LOSSES
lww: concurrent writes silently, clock-skew winners
crdt: same-key writes keep one arbitrary winner;

deletes leave tombstones, metadata grows
INVARIANTS
no merge strategy enforces domain rules
check seats >= 0 before write, or reconcile after
STACK
automerge: map, list, text, counter; losers in
getConflicts. yjs: Y.Map/Array/Text/Xml*, yata-based

The merge strategy is one decision in an offline build, and it is the kind we make explicit in the architecture phase of our custom software work rather than inherit from a default. If your app syncs offline writes today and nobody can name its conflict policy, send us the data model.

// next step

We work this way on client projects too.

Tell us what you are making. We reply with a plan, a timeline, and a price.

← the journal