Conflict Resolution
Understanding how Yjs CRDTs automatically merge concurrent changes.
Core Principle
CRDTs guarantee eventual consistency: all clients receiving the same operations converge to identical state, regardless of operation order.
Y.Map: Last-Writer-Wins
When multiple clients set the same key, the highest logical timestamp wins. When timestamps are equal, the client ID breaks ties (arbitrary but consistent ordering):
Alice: map.set('color', 'blue') // timestamp: 100
Bob: map.set('color', 'green') // timestamp: 101
Result: 'green' (Bob's timestamp higher)
// When timestamps match:
Alice: map.set('color', 'blue') // timestamp: 100, clientId: 'abc'
Bob: map.set('color', 'green') // timestamp: 100, clientId: 'xyz'
Result: determined by client ID comparison (consistent across all peers)Design tip: To preserve both values, use unique keys:
map.set(`comment-${odieId}`, 'X') // Both preserved
map.set(`comment-${bobId}`, 'Y')Y.Array: Position-Aware Merge
Concurrent insertions at the same position are both preserved:
Initial: [A, B, C]
Alice inserts X after A
Bob inserts Y after A
Result: [A, X, Y, B, C] or [A, Y, X, B, C]Order of X/Y is deterministic (by client ID) but arbitrary. Design UIs that tolerate this.
Y.Text: Character-Level Merge
Initial: "Hello World"
Alice: "Hello Beautiful World"
Bob: "Hello Amazing World"
Result: "Hello Beautiful Amazing World" (or reversed)Different formatting attributes merge (bold + italic). Same attribute uses LWW (both set color → one wins).
Designing for Good Merges
Use ID-based references - Indices shift; IDs are stable.
Separate order from content - Content edits and reordering merge independently.
Avoid computed data - Don’t store totals; compute when needed.
Use granular keys - user.name, user.email instead of one user object.
Accept non-determinism - Concurrent inserts at same position have arbitrary order.
When Automatic Merge Isn’t Enough
For critical data, detect conflicts and show resolution UI:
yMap.observe((event, transaction) => {
if (!transaction.local && hasConflict(event)) {
showConflictDialog(localValue, remoteValue)
}
})Example: Counters and votes. Rather than storing a single number (where concurrent increments get lost to LWW), track each user’s contribution separately:
// WRONG: concurrent increments overwrite each other
counter.set('votes', counter.get('votes') + 1)
// CORRECT: track per-user contributions, sum at read time
const userVotes = yDoc.getMap('votes')
userVotes.set(userId, (userVotes.get(userId) || 0) + 1)
function getTotalVotes(): number {
let total = 0
userVotes.forEach(count => total += count)
return total
}This pattern preserves all concurrent operations by giving each user their own key.
See Also
- ID-Based Storage - Stable references
- Transactions - Atomic operations