Conflict Resolution in Distributed Systems: From Last-Writer-Wins to CRDTs
Conflict Resolution in Distributed Systems#
When data is replicated across multiple nodes, concurrent writes create conflicts. Two users edit the same document. Two data centers accept writes for the same row. The system must decide: which write wins?
The answer depends on your consistency requirements, latency tolerance, and data semantics.
Why Conflicts Happen#
User A (DC-East) User B (DC-West)
│ │
▼ ▼
Write: name = "Alice" Write: name = "Bob"
│ │
▼ ▼
Node 1 Node 2
│ │
└──────── Replication ───────────────┘
│
▼
CONFLICT: name = ?
Conflicts are inevitable in any system that allows:
- Multi-leader replication (writes accepted at multiple nodes)
- Leaderless replication (Dynamo-style quorum writes)
- Offline-capable clients (mobile apps, local-first software)
- Eventual consistency models
Strategy 1: Last-Writer-Wins (LWW)#
The simplest approach — attach a timestamp to every write, keep the one with the highest timestamp:
Write A: { name: "Alice", timestamp: 1000 }
Write B: { name: "Bob", timestamp: 1001 }
Resolution: name = "Bob" (higher timestamp wins)
When LWW Works#
- Immutable data — event logs, sensor readings, append-only streams
- Idempotent updates — setting a value to a fixed state (active/inactive)
- Low-conflict workloads — user profiles where concurrent edits are rare
When LWW Fails#
User A sets price = $10 at t=1000
User B sets price = $15 at t=1001
LWW picks $15 — but User A's update is silently lost
User A adds item X to cart at t=1000
User B adds item Y to cart at t=1001
LWW picks {Y} — item X is silently lost
Silent data loss is the fundamental problem with LWW. The losing write disappears without any notification.
Clock Skew#
LWW depends on timestamps, but clocks across distributed nodes are never perfectly synchronized:
Node 1 clock: 12:00:00.000 (NTP-synced)
Node 2 clock: 12:00:00.150 (150ms ahead)
Write at Node 1 at "real" 12:00:00.100 → timestamp 12:00:00.100
Write at Node 2 at "real" 12:00:00.050 → timestamp 12:00:00.200
LWW picks Node 2's write — even though Node 1 wrote later
Use Hybrid Logical Clocks (HLC) to mitigate but not eliminate this problem.
Strategy 2: Version Vectors#
Track the causal history of every write to detect true conflicts:
Version Vector: { NodeA: 3, NodeB: 2 }
Means: this value incorporates NodeA's first 3 writes
and NodeB's first 2 writes
Conflict Detection#
Value X: version { A:3, B:2 }
Value Y: version { A:2, B:3 }
Neither dominates the other:
X has A:3 (greater than Y's A:2)
Y has B:3 (greater than X's B:2)
Result: CONFLICT DETECTED — must be resolved
Compare:
Value X: version { A:3, B:2 }
Value Z: version { A:3, B:3 }
Z dominates X (every component is greater than or equal):
Z has A:3 (equal to X) and B:3 (greater than X's B:2)
Result: NO CONFLICT — Z supersedes X
Version vectors give you accurate conflict detection without relying on wall clocks. Dynamo, Riak, and Voldemort all use them.
Strategy 3: Merge Functions#
When a conflict is detected, apply a deterministic merge function:
Conflict: cart_A = {apple, banana}
cart_B = {apple, cherry}
Merge function (set union): {apple, banana, cherry}
Common Merge Strategies#
| Data Type | Merge Function | Example |
|---|---|---|
| Sets | Union | Shopping cart items |
| Counters | Sum of deltas | Page view counts |
| Max value | Take maximum | High score |
| Text | Three-way merge | Document editing |
| Timestamps | Take latest | Last-seen-at |
Custom Merge Functions#
function mergeUserProfile(a, b, ancestor) {
return {
// LWW for simple fields
name: latest(a.name, b.name),
// Union for collections
interests: union(a.interests, b.interests),
// Application-specific logic
accountBalance: ancestor.accountBalance
+ (a.accountBalance - ancestor.accountBalance)
+ (b.accountBalance - ancestor.accountBalance),
}
}
The merge function must be commutative (order doesn't matter), associative (grouping doesn't matter), and idempotent (applying twice gives the same result).
Strategy 4: CRDTs (Conflict-Free Replicated Data Types)#
CRDTs are data structures mathematically guaranteed to converge without conflicts:
G-Counter (Grow-only Counter):
Node A: { A:5, B:0, C:0 } → total = 5
Node B: { A:0, B:3, C:0 } → total = 3
Node C: { A:0, B:0, C:7 } → total = 7
Merge: take max of each entry
Result: { A:5, B:3, C:7 } → total = 15
Common CRDT Types#
G-Counter (grow-only counter): Each node maintains its own counter. Merge takes the max of each node's count. Total is the sum.
PN-Counter (positive-negative counter): Two G-Counters — one for increments, one for decrements. Value = P - N.
G-Set (grow-only set): Elements can only be added, never removed. Merge is set union.
OR-Set (observed-remove set): Supports both add and remove. Each element tagged with a unique ID. Remove only affects observed tags.
LWW-Register: A single value with a timestamp. Merge keeps the highest timestamp. Same as LWW but formalized as a CRDT.
LWW-Element-Set: Combines LWW-Register semantics with set operations.
CRDT Trade-offs#
Pros:
✓ Mathematically guaranteed convergence
✓ No coordination needed (truly peer-to-peer)
✓ Works offline
✓ No conflict resolution logic to maintain
Cons:
✗ Limited data types (not everything fits a CRDT)
✗ Metadata overhead (version tags, tombstones)
✗ Tombstone accumulation (deleted items leave markers)
✗ Harder to reason about than single-leader writes
Real-World CRDT Usage#
- Redis CRDT — conflict-free geo-distributed Redis
- Automerge — CRDT library for JSON-like documents
- Yjs — CRDT for real-time collaborative editing
- Riak — built-in CRDT support for counters, sets, maps
Strategy 5: Application-Level Resolution#
Push the conflict to the application or user:
Conflict detected in document:
Version A: "Meeting at 2pm"
Version B: "Meeting at 3pm"
Show user:
┌─────────────────────────────────┐
│ Conflict detected! │
│ │
│ Version A: "Meeting at 2pm" │
│ Version B: "Meeting at 3pm" │
│ │
│ [Keep A] [Keep B] [Merge] │
└─────────────────────────────────┘
When to Use Application-Level Resolution#
- Semantic conflicts that only a human can resolve (scheduling conflicts, content edits)
- High-value data where silent resolution would be unacceptable (financial records)
- Collaborative editing where users expect to see and resolve conflicts
Cherry-Pick Strategies#
For complex documents, allow field-level cherry-picking:
Conflict in User Profile:
Field | Version A | Version B | Resolution
-----------|---------------|---------------|----------
name | "Alice Smith" | "Alice Smith" | Same (no conflict)
email | "a@old.com" | "a@new.com" | Pick B (newer)
phone | "555-0100" | null | Pick A (preserve data)
address | "123 Oak St" | "456 Elm St" | Ask user
Conflict-Free by Design#
The best conflict resolution is no conflicts at all. Design your data model to avoid them:
Techniques#
1. Single-leader per partition: Route all writes for a given key to one leader.
User ID 1-1000 → Leader Node A
User ID 1001-2000 → Leader Node B
No concurrent writes to same user possible
2. Append-only data models: Never update, only insert.
Instead of: UPDATE balance SET amount = 150
Use: INSERT INTO transactions (user_id, delta) VALUES (1, +50)
Balance = SUM(delta) — no conflicts possible
3. Operation-based design: Store operations, not state.
Instead of: cart = { items: ["apple", "banana"] }
Store: ADD "apple" at t=1
ADD "banana" at t=2
REMOVE "apple" at t=3
Replay operations in causal order → deterministic result
4. Reservation pattern: Acquire a lock or lease before writing.
Client → Reserve(item_id, duration=30s)
→ Write(item_id, new_value)
→ Release(item_id)
Choosing a Strategy#
Is data append-only?
└─ Yes → No conflict possible, use simple replication
└─ No ↓
Can you use single-leader per key?
└─ Yes → No conflict possible, route writes to leader
└─ No ↓
Is silent data loss acceptable?
└─ Yes → Last-Writer-Wins (simplest)
└─ No ↓
Does data fit a CRDT type?
└─ Yes → Use CRDTs (counters, sets, registers)
└─ No ↓
Can you write a deterministic merge function?
└─ Yes → Custom merge function with version vectors
└─ No → Application-level resolution (show user)
Key Takeaways#
- LWW is simple but lossy — only use it when silent data loss is acceptable
- Version vectors detect true conflicts — use them when you need accuracy over simplicity
- CRDTs guarantee convergence — ideal for counters, sets, and collaborative data
- Merge functions must be commutative, associative, and idempotent — or you get inconsistency
- Design conflict-free when possible — append-only models, single-leader partitioning, and operation-based design avoid the problem entirely
- No single strategy fits all — many systems combine multiple approaches for different data types
285 articles on system design at codelit.io/blog.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for Conflict Resolution in Distributed Systems in seconds.
Try it in Codelit →
Comments