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