Two people edited the same thing at the same time on different machines. Both writes are valid. Now what?
Prerequisites: Time & Clocks, Quorums Time to read: ~24 minutes
You allow writes on multiple replicas — for availability, for local write latency, or for offline support. Then this happens:
Replica A (Karachi): cart = {shoes, socks}
Replica B (Dubai): cart = {shoes, hat}
Network partition heals. Which is correct?
Neither. Both are legitimate writes by real users. The system must produce some answer, and the choice of answer is a design decision with real consequences.
🚨 You only have this problem if you allow concurrent writes to the same data on different nodes. Single-leader replication avoids it entirely by serializing everything through one node. The moment you choose multi-leader, leaderless, or offline-capable clients, conflict resolution becomes your problem. That’s the trade you made.
Before resolving, you must know a conflict exists. If replica B’s value is simply newer — B saw A’s write and then updated it — there’s no conflict, just a sequence. A conflict is when neither write saw the other.
Wall-clock timestamps cannot tell you this. A later timestamp might mean “happened after” or might mean “that machine’s clock is 200 ms fast.” → Time & Clocks
Version vectors can. Each replica tracks a counter per node:
Write on A: [A:1, B:0]
Write on B: [A:0, B:1]
Comparison: A's first element is greater, B's second is greater
→ NEITHER dominates → genuinely CONCURRENT → real conflict
versus
Write on A: [A:1, B:0]
Write on B after seeing A's: [A:1, B:1]
Every element of the first ≤ the second → ORDERED, not a conflict
🚨 This distinction is the whole value of version vectors: they tell you whether you have a real conflict or just a sequence. Without them, you’re guessing.
⚖️ The cost: the vector grows with the number of replicas that have ever written. Systems prune old entries, which risks false conflict reports. This overhead is why many modern systems prefer CRDTs, which don’t need explicit conflict detection at all.
Five strategies, from worst to best.
Keep the value with the highest timestamp. Discard the rest.
✅ Trivial. No metadata beyond a timestamp. Constant space.
❌ 🚨 It doesn’t resolve conflicts — it discards data, silently. No error, no log, no way to detect it later. A user’s write vanishes because another machine’s clock was 20 ms ahead, and neither the user nor your monitoring will ever know.
Acceptable when: the data is a regenerable cache, the value is approximate (a view counter), or there’s genuinely one legitimate writer (a user’s own setting).
Not acceptable when: losing a write is a bug. Which is most things.
📐 Cassandra uses LWW by default, which is why clock synchronization matters more there than people expect.
Return all conflicting versions (“siblings”); the application resolves them with domain knowledge.
versions = db.get("cart:42")
if len(versions) > 1:
merged = union_of_all(versions) # for a cart, union is right
db.put("cart:42", merged, context=versions)
✅ No data loss. The application has semantic knowledge the database can’t. ❌ Every read path must handle multi-version responses. Easy to forget, and forgetting means a crash or silently picking one.
🚨 This is what Dynamo does, and Amazon’s canonical example is instructive: for a shopping cart, union is the right merge, because “an item unexpectedly reappearing” is a far better customer experience than “the item you added is gone.” A deleted item might come back — annoying — but nothing a customer wanted is lost. That’s a product decision encoded in the merge function.
Same idea, but the merge logic is defined per data type up front:
| Data | Merge rule |
|---|---|
| Shopping cart | Union of items (bias toward keeping) |
| A counter | Sum of per-replica increments |
| A set of tags | Union |
| A document | Operational transform, or ask the user |
| A user profile | Field-by-field, LWW per field |
⚖️ Correct, but you must write and maintain a merge function for every conflicting type, and get the semantics right.
Git’s approach. Present both versions and let a human decide.
✅ Always correct — humans have the context. ❌ Only viable for user-facing documents where a person is present and willing.
The most elegant answer, and increasingly the modern one.
🚨 The core idea, and it’s genuinely clever:
Design the data structure so that merging is mathematically guaranteed to converge, regardless of the order or number of times merges happen.
The merge operation must be:
merge(a,b) = merge(b,a) (order doesn’t matter)merge(merge(a,b),c) = merge(a,merge(b,c)) (grouping doesn’t matter)merge(a,a) = a (duplicates are harmless)Given those three properties, every replica converges to the same value no matter what order updates arrive in, how many times they’re duplicated, or how the network misbehaves. No conflict detection, no coordination, no resolution logic.
The naive approach fails:
A: counter = 5 → 6
B: counter = 5 → 6
Merge: max(6, 6) = 6. Two increments, one counted. Wrong.
G-Counter (grow-only): each replica counts only its own increments.
A: {A: 1, B: 0} (A incremented once)
B: {A: 0, B: 1} (B incremented once)
Merge: element-wise max → {A: 1, B: 1}
Value: sum = 2 ✅ correct
Element-wise max is commutative, associative, and idempotent. It just works.
PN-Counter (supports decrement) keeps two G-Counters — increments and decrements — and returns the difference. You can’t decrement a G-Counter directly, because max() would discard it.
G-Set: add only. Merge = union. Trivially a CRDT.
🚨 Deletion is the hard part, because “remove X” and “add X” concurrently have no obvious winner.
2P-Set: two sets, added and removed. Once removed, an element can never be re-added — a real limitation.
OR-Set (Observed-Remove Set): tag each addition with a unique ID. A removal removes only the tags it has observed.
A: add("shoes") → {("shoes", tag1)}
B: add("shoes") → {("shoes", tag2)} (concurrently)
A: remove("shoes") → removes tag1 only
Merge: {("shoes", tag2)} → "shoes" is still present ✅
This matches intuition: B’s addition wasn’t seen by A’s removal, so it survives. Add wins over a concurrent remove.
RGA / LSEQ / Logoot and modern implementations (Yjs, Automerge) make collaborative text editing convergent — each character gets a unique, ordered identifier so concurrent insertions interleave deterministically.
🚨 This is what replaced Operational Transformation (OT) for many systems. OT (used by Google Docs) requires a central server to transform operations; CRDTs work peer-to-peer with no coordinator. Modern collaborative tools — Figma, Linear, and many local-first apps — use CRDTs for this reason. → Collaborative Editing case study
| State-based (CvRDT) | Operation-based (CmRDT) | |
|---|---|---|
| What’s sent | The full state (or a delta) | Individual operations |
| Requires | Nothing — merges are idempotent | Exactly-once, causally-ordered delivery |
| Bandwidth | Higher (though delta-CRDTs fix this) | Lower |
| Robustness | Very high — duplicates and reordering are fine | Depends on the messaging layer |
Delta-state CRDTs are the practical middle ground: send only the changed parts, keep the robustness.
They’re not free, and a balanced answer says so:
🚨 The key limitation to state: CRDTs guarantee convergence, not correctness. All replicas agree — on a value that might violate your business rules. You cannot enforce “balance must not go negative” with a CRDT.
flowchart TB
A{Can you avoid<br/>concurrent writes?} -->|yes| B[Single leader per key<br/>✅ simplest, no conflicts]
A -->|no| C{Does the data fit<br/>a CRDT?}
C -->|yes| D[Use a CRDT<br/>counters, sets, text]
C -->|no| E{Is losing a write<br/>acceptable?}
E -->|yes| F[LWW<br/>simple, lossy]
E -->|no| G{Can a human decide?}
G -->|yes| H[Surface both versions]
G -->|no| I[Application merge<br/>+ version vectors to detect]
🚨 Start at the top. The best conflict resolution is not having conflicts. If you can route all writes for a given key to one node — partition by user, by document, by account — you eliminate the entire problem class.
🎙️ “I’d first ask whether we can avoid concurrent writes entirely by making each record have a home region. That removes the problem rather than solving it. If we genuinely need multi-master — for offline support — then I’d use CRDTs for the data that fits, and version vectors plus an application merge for the rest.”
| Approach | Gain | Cost |
|---|---|---|
| Avoid conflicts (single writer per key) | No conflict logic at all | Loses local write latency and offline support |
| LWW | Trivial, constant space | Silently loses writes; clock-dependent |
| Version vectors + app merge | Detects real conflicts; no data loss | Metadata growth; merge logic per type |
| Siblings to the application | No loss; domain-aware resolution | Every read path must handle multiple versions |
| CRDTs | Automatic convergence, no coordination | Metadata overhead; limited operations; no invariants |
| Ask the user | Always semantically correct | Only for interactive, user-facing data |
1. Build a G-Counter. Three replicas, each incrementing independently, merging in random orders. Verify the total is always correct regardless of merge order or duplicated merges.
class GCounter:
def __init__(self, node_id):
self.node = node_id
self.counts = {}
def increment(self, n=1):
self.counts[self.node] = self.counts.get(self.node, 0) + n
def merge(self, other):
for k, v in other.counts.items():
self.counts[k] = max(self.counts.get(k, 0), v)
def value(self):
return sum(self.counts.values())
Then merge in every possible order and confirm you always get the same answer. That’s convergence, demonstrated.
2. Show why the naive counter fails. Replace the per-node dict with a single integer and
merge = max. Watch increments get lost. The contrast makes the design obvious.
3. Implement an OR-Set with add/remove and unique tags. Create a concurrent add and remove and verify the element survives. Then try a 2P-Set and observe that a removed element can never return.
4. Try Yjs or Automerge. Two clients editing the same document offline, then syncing. Watch the text merge without a server. Genuinely impressive to see, and it makes the collaborative-editing case study concrete.