Conflict Resolution

Only the Application Knows What the Merge Means

The store can tell you two versions conflict. It cannot tell you that two cart additions should union, two balance updates should compose, and two document titles need a human. The merge rule is domain knowledge, and it has to satisfy three algebraic properties or your replicas will never agree.

▶ Run the lab

The question this answers

The question

The system detected a conflict. How do I decide what the value should be?

The guarantee — the property claimed, and its scope

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.

What a node knows — observation versus inference

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.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
mergeconflict resolutionsemanticscommutativitysiblings

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.

  • Commutativef(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.
  • Associativef(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.
  • Idempotentf(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 components
A merge that satisfies the properties, and one that does not

Choosing 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 ruleWhy it worksWatch out for
Set of items (cart, tags, members)protocolUnion of adds, union of removesUnion is commutative, associative, idempotentRemove-then-add across replicas; needs an observed-remove design ([[crdts]])
Monotonic value (high score, max seen)protocolMaximum`max` is a join on a total orderOnly valid if the value truly never decreases
Counter (views, likes)protocolPer-replica counts, merged with max, summed on readTurns addition into a join, restoring idempotenceNaive summing double-counts on redelivery
Independent fieldsassumptionPer-field merge with a rule eachFields do not interactInvariants that span fields are silently broken
Text a human wroteassumptionPreserve both and ask, or use a text CRDT / OTNo rule reflects intentAuto-merging prose produces output nobody wrote
Value under a global invariant (balance)protocolDo not merge — coordinateA merge cannot enforce a constraint it cannot seeThis is the case CRDTs cannot solve ([[protecting-invariants]])
Data shape determines the merge rule

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.

Client-side resolution: read siblings, merge, write back with combined contexttypical
ClientReplica setGET → 2 siblings + context: deliveredGET → 2 siblings + contextPUT merged, ctx = both: deliveredPUT merged, ctx = bothsiblings: {book,lamp} and {book,mug} (write) at t=1siblings: {book,lamp} and {book,mug}merge → {book,lamp,mug} (decide) at t=4merge → {book,lamp,mug}stored, siblings collapse to one (write) at t=7stored, siblings collapse to onet=1time →t=7
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritedecide
The combined context is what collapses the siblings: it dominates both, so the merged value supersedes rather than branching again. A client that writes back without it creates a third sibling instead of resolving two.

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.

How it works
  • 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.
What can fail at the boundary
  • 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.
How it fails — what an operator sees
  • 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.
Where coordination is required
  • 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).
What still holds under failure
  • 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.
How it recovers
How you would know
  • 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.
When it helps
  • 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).
When it hurts
  • 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.
Simpler alternatives

A merge converges if and only if it is a join

A merge converges if and only if it is a join
Commutative, associative, idempotent — and deterministic. Break any one and two replicas that saw the same versions in different orders can settle on different values, permanently.
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}
commutative
yes
associative
yes
idempotent
yes
distinct final states
1
delivery orderfinal 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}
Every delivery order lands on the same state
Order does not matter, multiplicity does not matter, and grouping does not matter. That is what makes the rule safe to run at any replica at any time without coordinating with anyone — which is the entire reason the three properties are demanded.
The store detects the conflict; only the application knows what the merged value should be. Most correct merges turn out to be joins — a union, a maximum, or a per-field combination of those — because those are the shapes that satisfy all three properties without effort. Two traps are worth naming. Per-field merging quietly breaks invariants that span fields: merging start from one version and end from another can produce an interval that neither writer intended, so the merge unit has to match the constraint. And a merge cannot restore a global invariant at all — if two concurrent withdrawals overdrew an account, no function of the two resulting states puts the money back. That case needs coordination at write time, or compensation afterwards.
protocolThe three properties are checked exhaustively over the three versions shown, and the delivery orders are enumerated rather than sampled. That is a proof about these versions, not about every possible input — but a rule that already fails here fails in production too.

What people believe, and what is true

Claim

A good merge function can resolve any conflict.

Reality

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.

Claim

Field-level merging is always safer than object-level.

Reality

It is finer-grained, which helps when fields are independent and produces incoherent records when they are not.

Claim

If both replicas run the same code, they will converge.

Reality

Only if the rule is order-independent. Same code, different arrival order, different result is exactly the non-commutative failure.

Claim

Summing two counters is an obvious merge.

Reality

It is not idempotent. Redelivery inflates the total, and anti-entropy redelivers by design.

Claim

Asking the user is a cop-out.

Reality

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

Build it, then break 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.
Reason about this
  • 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.
Interview questions
  • 💬 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.