Local First Software

Sync conflicts are interface problems

4 min read

The merge algorithm is only half of a sync conflict.

The other half is the moment the user has to decide what happened, what can be kept, and whether recovery is possible. Exposing a storage-layer conflict directly in the UI makes the user debug replication machinery they did not choose to use.

Name the conflict

Two people changing a title is not the same problem as two devices changing different checklist items. An offline edit against a deleted record is not the same as a stale profile update.

Useful conflict states include:

  • the same field changed in two places
  • different fields changed and can merge
  • a remote record was deleted
  • a local action depends on stale data
  • permission changed while the client was offline
  • the schema changed before queued data arrived
  • the server rejected a queued action

These states need different product behavior. One generic “sync conflict” screen cannot explain all of them.

Merge the boring cases

Some conflicts should never interrupt the user.

If two devices update different fields, merge the fields. If one adds a checklist item while another edits a note, keep both. If a counter is incremented from multiple places, choose a data type or operation that represents that fact.

Automatic merging should still leave a trace when the result matters. A sync note, activity entry, or version history can provide confidence without turning every obvious merge into an approval dialog.

Show meaning when there is a choice

This is not enough:

Sync conflict. Choose local or remote.

The user needs the competing changes:

This title changed in two places.

Local change, 10:42 AM on this laptop:
"Q3 migration notes"

Remote change, 10:50 AM by Jordan:
"Q3 database migration notes"

For prose, a diff may be appropriate. For structured data, field-level choices may be clearer. For destructive conflicts, restore points matter more than a clever merge message.

The interface should translate versions, replicas, and last-write-wins policy into information a person can evaluate.

Preservation is a valid resolution

Writing, notes, and drafts often have a higher cost of loss than duplication. When the system cannot merge confidently, keeping both versions may be the least damaging choice:

  • create a duplicate draft
  • append both notes with labels
  • keep the remote version and save a local copy
  • move the unresolved record to a conflict folder
  • attach unsynced local edits to the record

It is less tidy than silently discarding one side. It also gives the user a way back.

Let ownership drive policy

Conflict behavior should follow what a field means.

fields:
  title:
    conflict: ask_user
  body:
    conflict: preserve_both
  server_status:
    conflict: server_wins
  local_draft:
    conflict: local_wins_until_submitted
  updated_at:
    conflict: computed

A server-owned status should not be treated like a user-owned draft. A computed summary can be regenerated. A draft body deserves aggressive preservation. Naming ownership in the model makes those choices explicit.

Delayed conflicts need a memory

Offline-first products create conflicts long after the original action. The user edits at 9:14 AM, the queue waits, and the server rejects the change hours later because the record disappeared or changed.

“Your offline edit from 9:14 AM conflicts with a newer server version” gives the user a starting point. “Sync failed” does not.

I would test same-field edits, different-field edits, local delete versus remote edit, remote delete versus local edit, revoked permissions, schema changes, rejected queued actions, and resolution followed by an app restart. The failure is not complete when the merge function returns; it is complete when the user can recover their work.

Jeremy London

About Jeremy London

Engineering leader and builder in Denver. I write about AI platforms, agents, security, reliability, homelab infrastructure, and the parts of engineering work that have to survive production.