WhollySoftware
Back to blogMobile

Local-First Data Sync with Conflict Resolution

Wholly Software TeamJanuary 22, 20267 min read
Local-First Data Sync with Conflict Resolution

A collaborative note-taking client's original sync design used simple last-writer-wins, and it worked fine until two users on the same shared document went offline in the same coffee shop, both edited different sections, and reconnected to find one user's changes silently overwritten. Last-writer-wins is easy to build and wrong for any data model where concurrent edits to different parts of the same object are both legitimate.

We moved to a field-level merge model rather than a full CRDT library for this project, because the data model — structured notes with discrete fields like title, tags, and a small number of content blocks — didn't need the generality (or complexity budget) of something like Automerge or Yjs. Each field carries its own last-modified timestamp and device ID, and the merge logic resolves at the field level: if two devices edited different fields, both changes apply; if they edited the same field, the later timestamp wins and the earlier edit is preserved in a local conflict log the user can review.

For the free-text content blocks specifically, field-level timestamps weren't granular enough — two edits to the same paragraph needed real text merging. We used a operational-transform-inspired diff-and-patch approach (built on top of a standard text-diffing library rather than a full OT implementation) that merges non-overlapping text insertions automatically and falls back to presenting both versions side by side for the rare genuinely overlapping edit, which happens in under 1% of syncs based on our production telemetry.

Vector clocks or timestamp-only ordering both have failure modes we had to account for — device clocks drift, sometimes by minutes, especially on older Android devices. We combined wall-clock timestamps with a monotonically increasing per-device counter as a tiebreaker, rather than trusting device time alone, after a bug where a device with a clock set two hours fast was consistently winning conflicts it shouldn't have.

The conflict log we built for surfacing rare unresolvable merges turned out to be as important as the merge algorithm itself — users trust a sync system more when they can see 'this edit conflicted and here's what happened' occasionally than when the system is silent and they later discover something's missing. We now build that visibility in from the start on any local-first project rather than treating it as a nice-to-have.

SyncOffline-FirstMobile ArchitectureData
Local-First Data Sync with Conflict Resolution — Wholly Software