The question this answers
The system detected a conflict. How do I decide what the value should be?
A merge function f(a, b) produces convergence across all replicas if and only if it is commutative (f(a,b) = f(b,a)), associative (grouping does not matter) and idempotent (f(a,a) = a). Under those three properties every replica reaches the same result regardless of the order or multiplicity in which versions arrive. Without them, replicas that see the same versions in different orders can settle on different values, permanently.
Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.
A replica knows the conflicting versions and their causal metadata, and knows the merge function it was given. It does not know user intent, does not know whether a field was left unchanged deliberately or simply not touched, and cannot see the invariant the two writes were jointly supposed to preserve. The merge function is the only channel through which application meaning reaches the replica, so anything not encoded there is unavailable.
A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.
The three properties, and what breaks without each
Merging happens in an unpredictable order: replica A may merge x with y and then with z, while replica B merges y with z and then with x, and any version may arrive more than once because retries and anti-entropy are both at play. For all replicas to land on the same value, the function must be indifferent to all of that.
These are not academic conditions. Each one has a concrete failure, and each failure looks like a system that "sometimes does not converge" — the hardest class of bug to diagnose, because it depends on delivery order you do not control and cannot reproduce.
The good news: most correct merges are naturally a "join" — set union, maximum, a per-field combination of those. If your rule is not obviously one of these, that is a signal to check the properties explicitly rather than assume them.
- Commutative —
f(a,b) = f(b,a). Without it, which replica received which version first changes the answer. Failure: two replicas holding different values forever, differing by arrival order. - Associative —
f(f(a,b),c) = f(a,f(b,c)). Without it, merging three versions in different groupings gives different results. Failure: divergence that appears only when three or more versions conflict, so it survives every two-replica test. - Idempotent —
f(a,a) = a. Without it, a version delivered twice changes the result. Failure: a counter that grows every time anti-entropy runs — the classic "our totals drift upward" bug. - Deterministic — no clock reads, no randomness, no map-iteration order, no locale-dependent comparison. Failure: replicas with identical inputs producing different outputs, which is maddening precisely because the inputs provably match.
1// GOOD — union of adds, minus union of removes. Commutative, associative,2// idempotent, deterministic. Order and duplication cannot change the result.3function mergeCart(a: Cart, b: Cart): Cart {4 return {5 added: union(a.added, b.added),6 removed: union(a.removed, b.removed),7 }8}9const items = (c: Cart) => difference(c.added, c.removed)10 11// BAD — not commutative. Whichever version arrives second wins the title,12// so two replicas seeing different arrival orders settle on different values.13function mergeDoc(a: Doc, b: Doc): Doc {14 return { ...a, ...b }15}16 17// BAD — not idempotent. Anti-entropy re-delivering a version inflates the total.18function mergeCounter(a: Counter, b: Counter): Counter {19 return { total: a.total + b.total }20}21// the fix is per-replica counts merged with max, summed on read:22// merge: { [r]: Math.max(a[r] ?? 0, b[r] ?? 0) } value: sum of componentsChoosing the rule: what the data means decides
There is no universal merge, and looking for one is the wrong instinct. What there is, is a small set of shapes that cover most real data — and the useful skill is recognising which shape a given field has.
Notice that the last row is a legitimate outcome, not a failure. Some conflicts genuinely have no automatic resolution, and the right answer is to preserve both and ask. Collaborative tools do this constantly and users accept it, because being shown a conflict is far better than being shown a silent revert.
One warning about "merge at the field level": it is usually right, and it can violate invariants that span fields. Merging country from one write and postalCode from another produces a record where neither writer's address is intact. When fields are jointly constrained, the merge unit must be the whole group, not the individual fields.
| Merge rule | Why it works | Watch out for | |
|---|---|---|---|
| Set of items (cart, tags, members)protocol | Union of adds, union of removes | Union is commutative, associative, idempotent | Remove-then-add across replicas; needs an observed-remove design ([[crdts]]) |
| Monotonic value (high score, max seen)protocol | Maximum | `max` is a join on a total order | Only valid if the value truly never decreases |
| Counter (views, likes)protocol | Per-replica counts, merged with max, summed on read | Turns addition into a join, restoring idempotence | Naive summing double-counts on redelivery |
| Independent fieldsassumption | Per-field merge with a rule each | Fields do not interact | Invariants that span fields are silently broken |
| Text a human wroteassumption | Preserve both and ask, or use a text CRDT / OT | No rule reflects intent | Auto-merging prose produces output nobody wrote |
| Value under a global invariant (balance)protocol | Do not merge — coordinate | A merge cannot enforce a constraint it cannot see | This is the case CRDTs cannot solve ([[protecting-invariants]]) |
Where the merge runs, and who sees the conflict
The rule has to execute somewhere, and the choice of location has real consequences.
On the server, at merge time. Simplest to reason about and applied consistently to every path. It requires the server to understand the data's semantics, which is awkward for a generic store and is why generic stores hand you siblings instead.
On the client, at read time. The client resolves siblings, presents or merges them, and writes back the result with the combined context (Version Vectors: Making the Conflict Visible). This puts the rule where the domain knowledge already lives, and it is what Dynamo-style systems expect. The cost: every client must implement it, and every client must implement it *the same way*, or clients fight each other by writing back different resolutions.
In the type. If the merge is a property of the data structure rather than a step in the code, no path can forget to apply it. That is a CRDT (CRDTs: Deterministic Merge, Not Correct Merge), and it is the most robust option where the semantics fit.
By a human. Show both versions and let the user choose or combine. This is the correct answer for creative content, and treating it as a failure of engineering is a mistake — a "both of these exist, which do you want?" dialog is a far better product than a silent revert.
Merging cannot restore an invariant
This is the boundary of the whole approach, and it is worth stating as sharply as possible.
A merge function sees two versions of an object. It does not see the constraint the application wanted to hold, and it usually cannot see the other objects that constraint involves. So a merge can produce a state that is internally coherent and globally illegal: two seats both booked because two replicas each thought the seat was free; a balance of −40 because two withdrawals each looked affordable; a username claimed twice because two registrations each found it available.
No merge rule fixes this, because the damage was done at write time, when both writes were accepted. Merging afterwards is choosing how to represent an already-broken state. The two honest responses are: prevent it by coordinating on the writes that touch the invariant (Start From the Invariant, Not From the Architecture, Coordination Avoidance: Restructuring the Problem Instead of Paying for It for how to keep that scope small), or compensate — accept the violation, detect it afterwards, and take a business action to repair it (Reconciliation Is a Component, Not a Cleanup Script, A Refund Is Not a Rollback). Overselling a flight and then bumping a passenger is the second option chosen deliberately, and it is a legitimate design.
The rule of thumb: if you cannot write a merge that keeps the invariant true, the invariant needs coordination. Reach for a cleverer merge only after you are sure it is not this case.
Key points
- The store detects conflicts; only the application knows what the merged value should be.
- A merge must be commutative, associative, idempotent and deterministic, or replicas can settle on different values permanently.
- Most correct merges are joins: union, maximum, or per-field combinations of those.
- Naive counter merges break idempotence and inflate on redelivery — use per-replica counts merged with max.
- Per-field merging breaks invariants that span fields; the merge unit must match the constraint.
- Client-side resolution must write back the *combined* context, or it creates another sibling instead of resolving.
- A merge cannot restore a global invariant. That case needs coordination at write time, or compensation afterwards.
The chain, answered
Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.
- • A conflict is detected between two or more versions of an object (Version Vectors: Making the Conflict Visible).
- • The versions are handed to a merge function, either at the server, at the client on read, or inside the data type.
- • The function combines them using a rule chosen for the data's semantics, ignoring arrival order entirely.
- • The merged result is written back with a context that dominates every input version, so it supersedes them.
- • Because the rule is a join, every replica performing the same merges in any order arrives at the same value.
- • The rule is not commutative or associative, so replicas diverge based on delivery order.
- • The rule is not idempotent, so redelivery during anti-entropy changes the value.
- • The rule reads a clock, a random source or an unordered map, making it non-deterministic across replicas.
- • The merged value is written back without the combined context and becomes a new sibling.
- • Two client versions implement the rule differently and repeatedly overwrite each other's resolutions.
- • The merge succeeds and produces a state that violates an invariant it could not see.
- • Permanent replica disagreement: a key returns different values depending on which replica serves the read, and repair never converges. The operator sees anti-entropy repairing the same keys over and over with no reduction in divergence.
- • Totals that drift upward: a non-idempotent counter merge inflates each time a version is redelivered. The operator sees a metric that only ever rises and never matches the source of truth.
- • Resolution ping-pong after a partial client rollout: two client versions merge differently and rewrite each other. The operator observes a burst of writes to one key with no user activity behind it.
- • Frankenstein records: per-field merging produces a record combining fields from two writers — a city from one address and a postal code from another. The operator sees data that passes validation and is nonetheless wrong.
- • Sibling count that never falls: clients resolve but write back without the combined context, so each resolution adds a version. Read sizes grow while the team believes resolution is working.
- • None for the merge itself — that is the point, and why this path stays available under partition.
- • Consistency of the *rule* across clients is a coordination problem outside the system: a shared library, a version gate, or server-side merge to make it structural.
- • Invariants that no merge can preserve require coordination at write time, which is where the availability cost reappears (Coordination Couples Availability).
- • Merging works during and after a partition with no communication needed, so availability is unaffected.
- • Convergence is guaranteed only if the properties hold; a defective rule fails specifically under the conditions that produce conflicts.
- • A merged value may be legal for the object and illegal for the system — the merge cannot detect this.
- • Versions arriving late or repeatedly are handled correctly by an idempotent rule and corrupt a non-idempotent one.
- • Detect: compare replica digests after anti-entropy completes; keys that remain divergent point at a broken merge rule (Merkle Trees: Finding the Difference Without Reading the Data).
- • Contain: move the rule server-side or behind a single shared library so one implementation exists.
- • Recover: for values corrupted by a non-idempotent merge, recompute from a source of truth rather than trying to repair in place (Source of Truth: The Question Every Inconsistency Incident Is Really Asking).
- • Reconcile: for invariant violations produced by a merge, take the business-level compensating action (A Refund Is Not a Rollback).
- • Verify: property-test the merge function for commutativity, associativity and idempotence with generated inputs. This is cheap and catches nearly everything.
- • Number of keys still divergent after a completed anti-entropy pass — the direct signal of a non-converging merge.
- • Siblings per read over time; a flat non-decreasing line means resolution is not collapsing anything.
- • Merge executions per key per hour with no corresponding user write, which detects resolution ping-pong.
- • Distribution of merge outcomes by rule, so you can see which fields actually conflict and how often.
- • Invariant violations found by a periodic checker (negative balances, duplicate unique values) — the only way to catch what the merge cannot see.
- • Any data with genuine multi-writer concurrency and a meaningful combination rule: carts, tag sets, preferences, presence, collaborative structures.
- • Where availability under partition matters more than a strict global order, and the data can be combined rather than chosen between.
- • As the alternative to LWW that costs nothing in availability and only design effort (Last Write Wins Is Data Loss You Chose by Default).
- • Data with a global invariant — merging cannot preserve it and pretending otherwise produces illegal states.
- • Prose and creative content, where automatic merge produces output nobody wrote and both authors dislike.
- • When the rule must live in many clients and cannot be kept consistent; that is an argument for server-side merge or a CRDT.
- • Where a single writer is achievable cheaply — the merge is complexity you did not need.
- • Use a CRDT so the merge is a property of the type and no code path can forget it (CRDTs: Deterministic Merge, Not Correct Merge).
- • Keep siblings and ask a human, which is correct for genuinely incompatible edits.
- • Use optimistic concurrency and reject the second write, converting the merge problem into a client retry against fresh state.
- • Serialise writes through one owner and delete the problem (Leader-Based Replication: Buying Order With a Single Writer, Hash Partitioning and the Modulo Trap).
- • Store the operations rather than the state, and derive the value by replaying them (The Log Is Not a Queue).
A merge converges if and only if it is a join
f(a, b) = { tags: a.tags ∪ b.tags, count: max(a.count, b.count) }
v1 = {tags:[red] count:1}
v2 = {tags:[blue] count:2}
v3 = {tags:[green,red] count:1}| delivery order | final state at that replica |
|---|---|
| v1 → v2 → v3 | {tags:[blue,green,red] count:2} |
| v1 → v3 → v2 | {tags:[blue,green,red] count:2} |
| v2 → v1 → v3 | {tags:[blue,green,red] count:2} |
| v2 → v3 → v1 | {tags:[blue,green,red] count:2} |
| v3 → v1 → v2 | {tags:[blue,green,red] count:2} |
| v3 → v2 → v1 | {tags:[blue,green,red] count:2} |
| v1 → v2 → v2 → v3(v2 delivered twice) | {tags:[blue,green,red] count:2} |
What people believe, and what is true
A good merge function can resolve any conflict.
It can resolve conflicts whose resolution is a function of the values. Conflicts about intent, and conflicts spanning an invariant, are not of that form.
Field-level merging is always safer than object-level.
It is finer-grained, which helps when fields are independent and produces incoherent records when they are not.
If both replicas run the same code, they will converge.
Only if the rule is order-independent. Same code, different arrival order, different result is exactly the non-commutative failure.
Summing two counters is an obvious merge.
It is not idempotent. Redelivery inflates the total, and anti-entropy redelivers by design.
Asking the user is a cop-out.
For content a person authored, it is the only rule that respects intent — and users prefer being asked to having work silently discarded.
Go deeper
Only the levels this lesson can honestly fill — a missing level is a claim nobody had.
Overview
The store says two versions conflict; you say what the answer is. The rule must not care about arrival order or duplication, or replicas will disagree forever.
Practical
Pick the rule from the data shape: union for sets, max for monotonic values, per-replica counts for counters, ask-a-human for prose. Property-test commutativity, associativity and idempotence. Write back with the combined context. Keep one implementation of the rule.
Advanced
A merge that is commutative, associative and idempotent is a join on a semilattice, and the states form a partially ordered set in which every pair has a least upper bound. Convergence is then a theorem rather than a hope, independent of delivery order and multiplicity. This is exactly the structure CRDTs formalise — an ad-hoc merge with these properties *is* a CRDT, whether or not you call it one. What the algebra cannot give you is any relationship between the join and a global invariant, which is why invariants remain a coordination problem.
Apply it
- 🔧 Property-test an existing merge function for commutativity, associativity and idempotence with generated inputs.
- 🔧 Find a per-field merge in your system and identify a pair of fields with a constraint between them. Decide whether the merge unit is wrong.
- ⚡ Anti-entropy repairs the same twelve keys every cycle and divergence never reaches zero. What do you suspect?
- ⚡ During a staged client rollout, one key receives thousands of writes with no user activity. Explain.
- 💬 What three properties must a merge function have, and what does each failure look like in production?
- 💬 Why is summing two counters the wrong merge, and what is the right one?
- 💬 Give an example of a conflict that no merge function can resolve correctly.