Conflict resolution in offline-first apps
Last-write-wins is almost always wrong. Here is what works in practice when two clients disagree.
Loading essay…
Last-write-wins is almost always wrong. Here is what works in practice when two clients disagree.
Loading essay…
Last-write-wins is almost always wrong. It is the default people reach for because it is one line of code and the demo works. The demo always works. The complaint comes from the customer who edited an invoice on their phone, switched to the laptop, and watched their phone-side change silently get overwritten. There is no error, no toast, no audit trail. Just absence.
If you are building anything that mutates shared state, you owe your users a more honest answer. Here is the one we converged on.
A conflict is two writes against the same logical row that, at the moment they were authored, both believed they were targeting the same parent state, but by the time the second one arrives the parent has moved.
That definition matters because most things people call conflicts are not. A user editing two different fields of the same row from two different devices is not a conflict, it is two compatible diffs. A user creating two children of the same parent is not a conflict at all. The real conflict, the kind that loses data when handled wrong, is when both writes target the same field of the same row and disagree.
When you detect a real conflict, there are four paths and each is appropriate somewhere:
If both edits targeted disjoint sets of fields on the same row, they
both land. This is the path that quietly handles the bulk of "conflicts"
in real apps: the user changed notes on the phone, the colleague
changed due_date on the desktop, both succeed.
The trap: you have to compute "which fields changed" against a known prior version, not against the freshest server row. Otherwise you treat fields the user did not touch as though they did.
For fields where merge is structurally unsafe (e.g. line items in an invoice, where you cannot meaningfully merge two different reorderings), the freshest write wins. The losing client gets a small inline notice:
"Your edit to the invoice items was replaced by a newer change from the desktop. Open history to see the previous version."
The receipt is the difference between LWW that respects users and LWW that surprises them.
Some changes are too important to silently merge or silently discard. Concretely: anything where the user typed a paragraph or anything financial. Show the user "your version" and "current version" side by side, two buttons. Make the choice theirs.
This is the only path where you should ever block the user. Make it fast: the merge UI is more important than the merge logic. Most apps fail at this step because the engineering team treats the UI as an afterthought.
For some entities (e.g. drafts, comments, personal notes), the right answer is to keep both writes as separate rows. The conflict is not real; the user thinks of them as different things and wants both. This is rare, but when it is right it is exactly right.
The mistake most engines make is trying to pick the resolution strategy dynamically per write, by inspecting the diff. We tried this; it produced edge cases nobody could explain. Instead, the strategy is declared once per entity type:
type Strategy =
| 'field-merge' // disjoint fields → both land
| 'last-write-wins' // freshest wins, losing client gets a receipt
| 'manual' // surface to user
| 'diverge'; // keep both as separate rows
const STRATEGY: Record<string, Strategy> = {
contact: 'field-merge',
invoice: 'manual',
invoice_item: 'last-write-wins',
payment: 'manual',
comment: 'diverge',
user_settings: 'field-merge',
};Boring. Predictable. When a customer reports something weird you go straight to the row in the table.
For the silent paths (field-merge and LWW), the only thing the user sees is a small notice in the activity feed:
Today, 2:14 PM — invoice #4729
notes updated from phone, due_date updated from desktop, merged.
Today, 2:09 PM — invoice #4729
items replaced by desktop edit. previous version available in history.
For the manual path, a small bottom-sheet on whatever device pushes second: "your edit conflicts with a newer change. Keep yours, or use theirs?" Two buttons, no scrolling, no history dump.
For diverge, nothing visible — both rows just exist.
version integer plus
the table above handles 99 % of writes. We use vector clocks only on
the small number of entities where collaborative editing is the actual
product feature (and at that point we are using a CRDT, not a
hand-rolled clock).Last-write-wins is a default that works as a demo and fails as a contract. Replace it with a per-entity table that picks merge, LWW, manual, or diverge as appropriate. Pay for the receipts UI. Resist the temptation to be clever. The customer who feels like the app respects their work will forgive a lot. The customer whose data silently disappears will not forgive anything.
If sync engines are interesting, the sync-engine series picks up where this one ends.