How do you resolve sync conflicts — last-write-wins vs CRDTs vs manual merge?
Two devices edit the same record offline — the interesting engineering starts the moment they both come back online and disagree.
LEARN BY DOINGPut your intuition to work3 steps
How do you resolve sync conflicts — last-write-wins vs CRDTs vs manual merge?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What a conflict actually is
Given the offline-first shape from the previous lesson, a conflict happens when two writers — say, a phone and a tablet signed into the same account — each make an optimistic local write to the same record while both are offline or partitioned from each other, and only discover the collision when both mutations reach the server (or when the server's version is pulled back down and it doesn't match what the device expected). The question isn't whether conflicts happen — with concurrent writers and unreliable networks, they will — it's what your merge function does when they do.
#Last-write-wins: simple, and simple to get wrong
The naive fix is to tag every write with a timestamp and let the highest one win. It's genuinely fine for a huge share of real cases — a user editing their own display name on one device rarely collides with anything. But the timestamp has to come from somewhere trustworthy: device wall-clock time drifts, and a phone with its clock five minutes fast will silently overwrite a colleague's more recent edit and never tell anyone. The fix most production systems use is a server-assigned monotonic version rather than device time — the server, not the phone, decides ordering.
data class Note(val id: String,val body: String,val serverVersion: Long)/*** The server is the only source of "which write is newer" — never trust* the device's wall clock for this comparison.*/fun resolveLastWriteWins(local: Note, remote: Note): Note =if (remote.serverVersion >= local.serverVersion) remote else local
Even done correctly, LWW's real cost isn't the algorithm — it's that it silently throws a user's edit away. There's no notification, no history, no chance to recover the losing write unless you separately keep it in an undo log.
#Version vectors: knowing when you can even trust LWW
A single version number tells you "newer or older" but not "concurrent." Two devices can each bump a record from version 3 to version 4 independently — same version number, genuinely different content, and neither one is causally derived from the other. A version vector (one counter per device/replica) lets you distinguish "B causally followed A, safe to just take B" from "A and B are concurrent, you need an actual merge policy" — which is the input CRDTs and manual merge both depend on to know when they're needed at all.
#CRDTs: merge functions that are mathematically guaranteed to converge
A CRDT (Conflict-free Replicated Data Type) is a data structure whose merge operation is commutative, associative and idempotent — which means any two replicas that have seen the same set of updates, in any order, converge to the same value automatically, with no coordination and no server arbitration needed. The simplest useful one is a G-Counter (grow-only counter): each replica keeps its own increment count, and merging two replicas is just an element-wise max.
data class GCounter(val counts: Map<String, Long> = emptyMap()) {fun increment(replicaId: String): GCounter =copy(counts = counts + (replicaId to (counts[replicaId] ?: 0L) + 1L))fun value(): Long = counts.values.sum()fun merge(other: GCounter): GCounter {val keys = counts.keys + other.counts.keysval merged = keys.associateWith { key ->maxOf(counts[key] ?: 0L, other.counts[key] ?: 0L)}return GCounter(merged)}}
Two phones can each increment a "like count" offline, merge in any order, on any device, and land on the same total with zero conflicts — because the merge never has to pick a winner, it just takes the max per replica. This generalizes to PN-Counters (increment and decrement), OR-Sets (add/remove with tombstones so a remove doesn't get resurrected by a late add), and LWW-Registers (a CRDT wrapper around the timestamp approach above, made safe by attaching a replica ID as a tiebreaker). Real collaborative text editors — this codebase's own Yjs-based collaborative editor is a working example — use CRDTs for exactly this reason: every keystroke from every participant merges deterministically without a central lock.
#Manual merge: when the data is intent, not a value
CRDTs work because the operations have well-defined merge semantics — counters add, sets union. They don't help when two edits represent conflicting human intent that no algorithm can arbitrate: a user changed the shipping address on their phone to "work" while changing it on their laptop to "mom's house." Neither is more correct. Here the honest move is to surface both versions to the user — a conflict screen, similar in spirit to Git's conflict markers — and let them pick or merge by hand, rather than silently guessing and shipping a package to the wrong place.
🧠 Check your understanding
0/1 · 0/1 answered1. Why can't a single version number (as opposed to a version vector) always tell you whether two edits are concurrent or one causally follows the other?