A user logs breakfast on their phone on a flight. Their tablet, still at home and online, edits the same day’s lunch. The phone lands, reconnects, and your sync code now has two versions of one day. “Last write wins” picks one and throws the other away, and someone’s breakfast disappears. Good offline first sync conflict resolution starts from the idea that there isn’t one right merge rule. Different kinds of data need different rules.

Crumb Count, our nutrition app, runs offline first with nine conflict policies, one per kind of data, including tombstone deletes, versioned migrations, and consent fields that are exempt from merging. The patterns below come from that work. None of them depend on Flutter: they apply just as well to a React Native app on SQLite, a native app on Core Data or Room, or a web app with IndexedDB.

Pick a conflict policy per datatype

Before writing sync code, list every kind of record your app stores and answer one question for each: if two devices change this while offline, what should the user see afterwards? You’ll get a short list of different answers. Some common ones:

  • Append-only logs (meal entries, workout sessions, journal notes). Two devices creating entries isn’t a conflict at all. Give every record a client-generated ID (a UUID, not an auto-increment), and the merge is a union. Most “conflicts” in apps like this disappear once IDs stop colliding.
  • Profile-like records (name, height, goals). Merge field by field, not record by record. If the phone changed the goal and the tablet changed the height, keep both. Track a timestamp per field and pick the newer value for each one.
  • Counters and totals (glasses of water today). Never sync the total. Sync the increments and derive the total. Two devices each adding one glass should give two, not one.
  • Device-local settings (theme on this device, last tab opened). Don’t sync them. Not everything belongs on the server.
  • Server-owned data (subscription status, quotas, reference catalogues). The client never wins. It caches and gets overwritten.

Write the policy down next to the schema so nobody has to guess. It can be as plain as a table in code:

const syncPolicy = {
  mealEntry:    'union-by-id',
  profile:      'field-level-lww',
  waterCount:   'sum-of-deltas',
  subscription: 'server-wins',
  consent:      'append-only-ledger',
};

A sync engine with one generic merge function and a lookup table is easier to review than one with special cases scattered across repositories.

Don’t trust device clocks

“Newer” needs a clock, and phone clocks drift, get set by hand, and jump across time zones. For field-level merges, use a hybrid logical clock (wall time plus a counter that only moves forward) or let the server stamp the write when it arrives and keep a per-device sequence number for ordering within one device. Either is fine. Raw DateTime.now() on the client isn’t.

Deletes need tombstones

The classic offline bug: a user deletes an entry on device A. Device B, offline, still has it. B syncs, the server sees a record it doesn’t have, and the deleted entry comes back from the dead.

The fix is to never really delete during sync. A delete becomes an update that sets deletedAt and keeps the ID. Every device that syncs learns the record is gone, and a stale copy coming back up loses to the tombstone. Queries hide tombstoned rows. A cleanup job removes them later, once you’re confident every active device has synced past that point. Pick that window on purpose (the longest a device might reasonably stay offline) and accept that a device offline for longer than that should do a full resync rather than a merge.

Versioned migrations for offline first sync conflict resolution

Offline-first means old app versions keep writing data for weeks after you ship a new schema. Users don’t update on your schedule. So:

  • Stamp every record with a schema version. The client writes the version it understands.
  • Migrate forward on read, on the server and on clients. Each version step is a small, pure function from version N to N+1, and they chain. Never migrate backwards.
  • Preserve fields you don’t recognise. If version 3 of the app reads a version 4 record, edits one field and writes it back, it must not drop the fields it doesn’t know about. Keep unknown keys and send them back untouched. This one rule prevents a whole class of silent data loss.
  • Gate destructive changes on a minimum app version. If a migration can’t be made safe for old clients, force the update before you turn it on.

Some fields must never merge

Consent is the clearest case. Under GDPR Article 7(1), where processing is based on consent, the controller must be able to demonstrate that the person consented. A consent flag that gets merged by timestamp can’t demonstrate anything: you can’t say when it was given, on which screen, against which version of the policy, or whether a sync conflict flipped it.

So in Crumb Count, consent fields are exempt from merging altogether. Model them as an append-only ledger instead: each grant or withdrawal is its own immutable event, with the time, the policy version and the device. The current state is derived from the latest event, the history is the audit trail, and there’s nothing for a conflict resolver to get wrong. The same treatment suits anything with legal or financial weight: accepted terms, payment authorisations, age confirmations.

How to test it

Conflict code fails in combinations, so test combinations. Two simulated clients, a fake server, a scripted sequence of offline edits, then sync in every order and check that both clients end up with the same state. Add a test per policy, plus one for the old-client-writes-new-record case. It’s a few hundred lines of test code and it catches the bugs that otherwise show up as support tickets you can’t reproduce.

At orithLabs we build offline-first apps on Flutter, native and web stacks, and sync design is usually the part we spend the most time on before writing code. If you’re planning an app that has to work on a bad connection, our mobile development service is a good place to start, and Crumb Count shows these policies in a shipped product.