| name | conflict-resolution |
| description | Pragmatic conflict resolution for mobile sync - last-write-wins, field-level merge, three-way merge, vector clocks, and CRDT basics. Use when two clients can modify the same record offline. |
Conflict Resolution
Instructions
Any bidirectional sync has conflicts. The question is not "will we have them" but "how do we resolve them predictably". Pick a policy per entity, make it explicit, and show pending or conflicted states in the UI.
1. Conflict Models
| Model | Works by | Good for |
|---|
| Last-write-wins (LWW) | Highest timestamp wins per record | Settings, profile fields, simple data |
| Field-level LWW | Per-field timestamps merge | User profile with independently edited fields |
| Three-way merge | Base + ours + theirs -> merged | Documents, code-like content |
| Vector clocks | Track causality | Systems with unreliable clocks |
| CRDTs | Conflict-free data types | Collaborative docs, shared sets |
| Manual | Ask the user | Irreducible conflicts, high-stakes data |
2. Last-Write-Wins
The easiest policy. Each update carries a server-authoritative timestamp or revision.
data class Article(val id: String, val title: String, val body: String, val rev: Long, val updatedAt: Instant)
fun resolve(local: Article, remote: Article): Article =
if (remote.rev >= local.rev) remote else local
Trap: naive wall-clock LWW drops edits when devices have skewed clocks. Use server-assigned revisions or hybrid logical clocks (HLC).
3. Field-Level LWW
Good when different fields are edited on different devices.
record: { title: {v:"A", ts:t1}, body: {v:"B", ts:t2} }
On merge, pick the higher timestamp per field. Keep the change envelope small by sending only changed fields.
4. Three-Way Merge
When you know the base version the client started from, the server can merge:
merge(base, ours, theirs) =
if ours == base: theirs
elif theirs == base: ours
elif ours == theirs: ours
else: conflict -> policy (prefer ours, prefer theirs, prompt user)
For text, use a diff/patch library. For structured records, do it field-by-field.
5. Vector Clocks
When wall clocks cannot be trusted and causality matters:
- Each replica keeps a vector
{replicaId: counter}.
- On update, increment the local replica's counter.
- On merge, compare vectors:
- If A <= B (every component), B is newer.
- If neither dominates, concurrent -> conflict.
Vector clocks detect conflicts; they do not resolve them. Pair with a resolution policy.
6. CRDTs (Conflict-Free Replicated Data Types)
CRDTs guarantee automatic merge without conflicts:
- Counters (G-Counter, PN-Counter) -- increments merge by summing per replica.
- Sets (OR-Set, LWW-Set) -- add/remove operations merge deterministically.
- Text (RGA, Yjs, Automerge) -- concurrent edits merge into a consistent sequence.
- Maps -- nested CRDTs.
Use Yjs, Automerge, or a language-native port. Cost: payload growth from metadata, operation history, and garbage collection.
7. Pragmatic Recipe for Most Apps
- Default to server-assigned revisions + LWW at the record level.
- Switch to field-level LWW for profile-like entities with independent edits.
- Switch to three-way merge for documents with structured content.
- Reserve CRDTs for truly collaborative content (multi-cursor docs, shared boards).
- Always have a manual fallback for high-stakes data (medical, financial).
8. Client UI for Conflicts
Do not fail silently. Conflicts need affordances:
- Pending state: local change sent, awaiting ack.
- Synced state: server ack received.
- Conflict state: show both versions, let the user pick, or default to policy with undo.
- Keep an in-app history/log of conflict resolutions for 7-30 days.
enum SyncState: Equatable {
case synced
case pending
case conflict(local: Article, remote: Article)
case failed(reason: String)
}
9. Deterministic Behavior
- Every merge function is pure and testable. Write unit tests that exercise LWW, concurrent edits, and deletions.
- Deletes need special handling: use tombstones so a delete does not get resurrected by a stale offline edit.
- Document idempotency: applying the same resolution twice must yield the same state.
10. Anti-Patterns
- Silent server overwrites. The user loses work without signal.
- Client-side timestamps as the source of truth without clock validation.
- Mixing CRDTs and LWW within one entity.
- Resolving conflicts in UI code. Keep the policy in a domain function; the UI presents outcomes.
- Letting tombstones accumulate forever. Garbage collect.
11. Testing Conflicts
- Simulate two clients editing the same record offline, then reconnecting in either order.
- Property-based tests for merge commutativity (
merge(a,b) == merge(b,a)) where applicable.
- Fuzz the resolution function with random histories.
- In CI, run an end-to-end scenario with two emulated devices against a test server.
Checklist