Skip to content
ALI HAITHAM·TECH·responds in <1h
  • Homestart here
  • Workcase studies, projects
  • Serviceswhat I will build for you
  • Writingessays, series
  • Traincourses, labs, cohorts
  • Aboutthe engineer behind this site
Sign inStart a project
ALI HAITHAM · TECH
  • Home↗
  • Work↗
  • Services↗
  • Writing↗
  • Train↗
  • About↗
Sign inStart a project
Online
ALI HAITHAM·TECH

Engineering studio. Damascus, GMT+3.

aliyosef.online

Studio

  • Work
  • Writing
  • Training
  • About
  • Contact me

Portal

  • Sign in
  • Open a ticket
  • Track project
  • My account

Resources

  • Docs
  • Status
  • Changelog
  • Brand kit
  • Privacy
  • Terms

Newsletter

Field notes and tech news. Weekly. No fluff.

Free. Unsubscribe anytime.

Find me elsewhere
© 2026 Ali Haitham Yosef. All rights reserved.Hand-built in React 19. No frameworks of frameworks.Last deployed · 2026-05-08All systems operational
  1. Home/
  2. Writing/
  3. Sync engines
Sync engines

Conflict resolution in offline-first apps

Last-write-wins is almost always wrong. Here is what works in practice when two clients disagree.

April 28, 2026·10 min·by Ali Yosef

Loading essay…

ShareXLinkedInRSS
Get the next essay in your inbox.New posts every Sunday morning. Engineering, in plain language. Free, unsubscribe anytime.Subscribe→
AY
Ali Haitham Yosef

Engineer and writer. Builder of multi-tenant platforms, sync engines, and quietly reliable systems. 600+ shipped projects since 2015.

SubscribeStart a project→

Read next

  • PB
    May 5, 2026·Sync engines

    When polling beats websockets

    A year of trade-offs in Mohaseb. Why the obvious answer was wrong on our network conditions.

    8 min
  • SE
    Series
    Apr 20, 2026·Sync engines

    Building a sync engine, part one: the queue

    Series opener. The shape of an outgoing queue, why ordering matters, and the test harness that catches everything.

    14 min
  • RS
    Apr 12, 2026·Sync engines

    The reconnect storm problem

    When 800 phones come back online at once, what happens to your server. The patterns that survive.

    7 min
←Back to all essays

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.

What "conflict" actually means#

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.

The four honest paths#

When you detect a real conflict, there are four paths and each is appropriate somewhere:

1. Field-level merge#

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.

2. Last-write-wins, with a receipt#

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.

3. Manual reconciliation#

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.

4. Diverge#

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.

Picking the path per entity, not per write#

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.

The unstated rule

The resolution strategy belongs to the product, not to the engine. Engineering picks the table; product owns which strategy each entity gets. That conversation is the right place to surface assumptions: "we use last-write-wins on invoice items, are you OK with that?"

What the user sees#

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.

What we deliberately do not do#

  • No vector clocks for the dominant case. A 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).
  • No "auto-resolve by AI" path. It works in demos and produces apologies in production.
  • No write blocking by default. A conflict in the queue does not prevent the next write from going through; it sits, gets resolved, and the queue continues.

The honest summary#

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.