Conflict Resolution
This is Part 8 of the Offline-First Flutter series — the part we kept deferring, because it's the hard core of sync. We've been flagging "this is a conflict, handle it later" in push and pull. Later is now.
A conflict happens when the same record is changed in two places since their last common sync. You edit note X on your phone (offline); meanwhile it changed on the server (your laptop, a collaborator). Now there are two valid versions and the system must decide what the one truth becomes. There is no universally "correct" answer — only strategies with different trade-offs. Your job is to choose deliberately, not accidentally.
Let's go through the strategies from simplest to most powerful, then build a practical one for FieldNotes.
Stack: Drift + abstract REST, Flutter 3.38 / Dart 3.12. Builds on push and pull/merge.
The analogy: two editors, one document
Analogy — the offline doc. Two editors take the same document home. With no internet, both edit it overnight. In the morning they must produce one document. Options: keep whoever saved last (lose the other's work), keep both copies, or carefully merge — take the new title from one and the new paragraph from the other. Each option is reasonable in different situations. Conflict resolution is choosing the morning policy before the editors go home.
The key realization: conflicts are inevitable in any system that allows offline edits. You can't prevent them — you can only resolve them predictably. A system without an explicit policy resolves them randomly, which means silent data loss.
Detecting a conflict
Before resolving, you must detect. Two mechanisms (from Part 2):
| Mechanism | How it detects | Weakness |
| --- | --- | --- |
| Timestamps (updatedAt) | Compare local vs remote modify time | Clock drift across devices; ties |
| Version counter (version) | Server stores a version; client sends the base version it edited from; mismatch = conflict | Needs server cooperation |
The robust detector is the version counter. When the client pushes, it sends "I edited starting from version 4." If the server's current version is still 4, no conflict — accept and bump to 5. If the server is already at 6, someone else changed it since version 4 → conflict. This is clock-independent and precise, unlike timestamps. It's the same idea as optimistic concurrency control in databases (and HTTP
ETag/If-Match).
// On push, send the base version we edited from:
final result = await api.pushNote(note); // body includes note.version (the base)
// Server logic (conceptual):
// if (serverVersion == note.version) { accept; serverVersion++; }
// else { return conflict, with the server's current note }
if (result.conflict) {
await _resolveConflict(local, result.serverNote!); // strategies below
}
Strategy 1: Last-Write-Wins (LWW)
The simplest: the version with the later updatedAt wins, the other is discarded.
Future<void> _resolveLWW(Note local, RemoteNote remote) async {
if (remote.updatedAt.isAfter(local.updatedAt)) {
await _overwriteWithRemote(remote); // server newer → take it
} else {
// local newer → keep local, re-push it (it'll win on the server too)
await _markForRepush(local);
}
}
| ✅ Pros | ❌ Cons | | --- | --- | | Trivial to implement | Silently discards the loser's edits | | Deterministic | Vulnerable to clock drift (wrong winner) | | Fine for "latest value" data (a setting, a status) | Bad for documents where both edits matter |
Use LWW for fields where only the latest value matters — a toggle, a last-known location, a status. Avoid LWW for rich content (a note body) where discarding the loser means losing real work. And if you use LWW, prefer the server's clock or a logical version for the comparison, not unsynchronized device clocks.
Strategy 2: Server-authoritative versioning (optimistic concurrency)
Make the server the referee. It accepts a change only if the client edited from the current version; otherwise it rejects with its current state, and the client rebases (re-applies its edit on top) and re-pushes.
client edits from v4 → push(base=4)
server at v4? → accept, now v5 ✅
server at v6? → reject (conflict), return server's v6
client merges its edit onto v6 → push(base=6) → accept, v7 ✅
This is the backbone of a reliable system: no change is accepted that wasn't based on the latest version, so updates can never silently clobber each other on the server. The client then decides how to merge (LWW, field-merge, ask the user). Pair this with idempotent pushes (Part 6) and you have a sound concurrency model.
Strategy 3: Field-level merge
When edits touch different fields, you can often keep both. If one side changed the title and the other changed the body, merge them — no data lost:
Future<void> _resolveFieldMerge(Note local, RemoteNote remote, Note base) async {
// 'base' = the last common version both started from.
final merged = NotesCompanion(
id: Value(local.id),
// For each field: if only one side changed it (vs base), take that side.
// If BOTH changed it, fall back to a per-field rule (e.g. LWW).
title: Value(_pickField(base.title, local.title, remote.title, local, remote)),
body: Value(_pickField(base.body, local.body, remote.body, local, remote)),
version: Value(remote.version + 1),
isDirty: const Value(true), // re-push the merged result
);
await (db.update(db.notes)..where((n) => n.id.equals(local.id))).write(merged);
}
String _pickField(String base, String local, String remote, Note l, RemoteNote r) {
final localChanged = local != base;
final remoteChanged = remote != base;
if (localChanged && !remoteChanged) return local; // only local changed it
if (remoteChanged && !localChanged) return remote; // only remote changed it
if (!localChanged && !remoteChanged) return base; // neither
return r.updatedAt.isAfter(l.updatedAt) ? remote : local; // both → LWW per field
}
Field-merge needs the base version (the last common ancestor both edited from) to know which side actually changed each field — exactly like a 3-way merge in git. Store the last-synced snapshot (or fetch it) to enable this. It's more work but loses far less data than whole-record LWW.
Strategy 4: Version vectors (detecting true concurrency)
A plain counter can't tell causal updates from concurrent ones across many replicas. Version vectors (a map of replicaId → counter) can: if neither vector "dominates" the other, the edits were truly concurrent (a real conflict) rather than one following the other.
// {deviceA: 3, deviceB: 1} vs {deviceA: 2, deviceB: 2}
// Neither dominates → concurrent → conflict. Otherwise one is an ancestor → no conflict.
Version vectors give precise concurrency detection for multi-device/multi-user systems — but they add real complexity (per-replica bookkeeping, pruning). For a 1–2 device app like FieldNotes, a server version counter is enough. Reach for vectors when many independent replicas edit the same data offline and you must distinguish "B's edit came after A's" from "A and B edited at the same time."
Strategy 5: CRDTs (conflict-free by construction)
Conflict-free Replicated Data Types are data structures designed so that concurrent edits always merge deterministically with no conflict — by mathematical properties (commutative, associative, idempotent merges). Collaborative text editors use them so two people typing in the same paragraph just merge.
CRDTs are the gold standard for real-time collaborative data (shared text, lists), but they're heavyweight and not worth hand-rolling for most apps. If you need them, use a library or a managed framework — this is exactly where PowerSync, Yjs-style CRDTs, or specialized backends earn their keep. For FieldNotes' simple notes, we don't need CRDTs.
Strategy 6: Ask the user (keep both)
Sometimes the honest answer is "a human must decide." Keep both versions and let the user pick or merge:
// Materialize the remote version as a separate note so nothing is lost:
await db.into(db.notes).insert(remote.toCompanion(
id: const Uuid().v4(), // new id = a copy
titleSuffix: ' (conflicted copy)',
isDirty: false,
));
// Keep the local edit as-is; surface a "resolve conflict" prompt in the UI.
This is what Dropbox's "conflicted copy" does and Apple Notes' merge prompt. It never loses data and defers the judgment to the only entity that truly knows intent — the user. Use it for high-value content where silent resolution is unacceptable.
The delete-vs-edit conflict
A special case: note deleted on the server, but edited locally (or vice versa) — handled in pull merge. Decide a policy:
| Policy | Behavior | When | | --- | --- | --- | | Edit wins (resurrect) | The edit "undeletes" the note | Safer default — don't lose new content | | Delete wins | The note stays deleted, edit discarded | When deletes are authoritative | | Ask | Surface both to the user | High-value data |
Default to "edit wins" for user content. Losing a freshly written note because someone deleted it elsewhere is the worse surprise. Resurrect it (clear the tombstone, re-push) so the new content survives, unless your domain says deletes are final.
A practical strategy for FieldNotes
Combine the pragmatic pieces:
- Detect conflicts with a server version counter (optimistic concurrency) — robust, no clock reliance.
- Default resolution: field-level merge using the last-synced base (title and body independently), falling back to LWW per field when both sides changed the same field.
- Delete-vs-edit: edit wins (resurrect) for safety.
- For irreconcilable, high-value cases, keep a conflicted copy rather than lose data.
- After resolving, mark the merged row dirty and re-push so the resolution lands on the server too.
Future<void> _resolveConflict(Note local, RemoteNote remote) async {
final base = await _lastSyncedSnapshot(local.id); // last common version
await _resolveFieldMerge(local, remote, base); // merge, mark dirty
// next push sends the merged note (based on remote.version) → server accepts
}
The meta-lesson: there's no free lunch. LWW is simple but lossy; field-merge saves more but needs a base; vectors and CRDTs are powerful but complex. Pick the simplest strategy that doesn't lose data your users care about — and make the choice explicit, because the alternative is losing data implicitly.
Practice Challenges
Challenge 1 — Define it. In one sentence, when does a conflict occur?
Show solution
When the same record is changed in two places since their last common sync, producing two divergent versions that must be reconciled into one.
Challenge 2 — LWW's flaw. Give a concrete case where last-write-wins loses important data.
Show solution
Two users edit the same note offline: A rewrites the body, B fixes a typo in the title, B saves slightly later. LWW keeps B's whole note and discards A's new body entirely — real work lost. Field-merge would keep both. (Clock drift can also pick the wrong "last" writer.)
Challenge 3 — Version counter. Explain how a server version counter detects a conflict without relying on clocks.
Show solution
The client pushes the base version it edited from. If the server's current version equals it, accept and increment. If the server is ahead, someone changed the record since that base → conflict. It's purely comparing integers the server controls, so no device clocks are involved.
Challenge 4 — Why a base version? Why does field-level merge need the last common (base) version?
Show solution
To know which side actually changed each field. Comparing local and remote to the base (a 3-way merge) reveals "only local changed the title" vs "both changed the body." Without the base you can't tell a change from a non-change and can't merge safely.
Challenge 5 — Delete vs edit. A note is deleted on the server but edited locally. What's the safe default and why?
Show solution
Edit wins (resurrect): clear the tombstone and keep/re-push the edited note. Losing freshly written content because of a remote delete is the worse outcome for user-generated data — unless the domain treats deletes as final.
Questions to test yourself
Q1 (basic). What is a sync conflict?
Show answer
When the same record was modified in two places since their last common sync, yielding two valid divergent versions that must be reconciled into one truth.
Q2 (basic). What's the trade-off of last-write-wins?
Show answer
It's trivial and deterministic, but silently discards the losing version's edits and is vulnerable to clock drift (possibly picking the wrong winner). Fine for "latest value" fields, bad for rich content.
Q3 (intermediate). Why is a version counter a more robust conflict detector than timestamps?
Show answer
It's clock-independent: the server compares the client's base version against its current version, so cross-device clock drift and timestamp ties can't cause wrong detection. It's optimistic concurrency control, like HTTP ETag/If-Match.
Q4 (intermediate). How does field-level merge reduce data loss, and what does it require?
Show answer
It merges non-overlapping field changes (e.g. title from one side, body from the other) so neither is lost, only falling back to a per-field rule when both changed the same field. It requires the last common base version to determine which side changed each field (a 3-way merge).
Q5 (advanced). When do you need version vectors or CRDTs instead of a simple counter?
Show answer
Version vectors when many independent replicas edit offline and you must distinguish concurrent edits from causally ordered ones (a single counter can't). CRDTs when you need automatic, conflict-free merging of collaborative structures (shared text/lists) in real time. Both add significant complexity, so use them only when the domain (multi-replica/collaborative) demands it — often via a library/managed framework.
Q6 (advanced). Describe a sound, practical conflict strategy for a notes app and why each piece is chosen.
Show answer
Detect with a server version counter (robust, clock-free). Resolve with field-level merge off the last-synced base, falling back to per-field LWW when both changed the same field (minimizes loss). Delete-vs-edit: edit wins (don't lose new content). For irreconcilable high-value cases, keep a conflicted copy (never lose data). After resolving, mark dirty and re-push so the server converges. Each choice favors the simplest option that doesn't lose data users care about, made explicitly.
Wrapping up
- Conflicts — the same record changed in two places — are inevitable with offline edits; you can only resolve them predictably.
- Detect with a server version counter (optimistic concurrency), which beats clock-based timestamps.
- Strategies, simplest → strongest: LWW (lossy, for latest-value fields) → field-level merge (needs a base; 3-way merge) → version vectors (true concurrency detection) → CRDTs (auto-merge, for collaboration) → ask the user / keep both (never lose data).
- Delete-vs-edit: default to edit wins (resurrect) for user content.
- For FieldNotes: version-counter detection + field merge + edit-wins + conflicted-copy fallback, then re-push the resolution. Choose the simplest strategy that doesn't lose data your users care about — explicitly.
In Part 9 we make all this visible and pleasant: optimistic UI and sync status — instant local updates, per-note "syncing/synced/failed" indicators, and graceful error/retry UX.