Skip to main content

Understanding Conflicts

In distributed systems with realtime synchronization, conflicts occur when multiple clients or processes attempt to modify the same data concurrently. LiveSync provides mechanisms to detect, prevent, and resolve these conflicts.

Types of Conflicts

1. Concurrent Updates

Multiple users modifying the same resource simultaneously:
// User A and User B both edit the same document
// User A: Changes title to "Project Alpha"
// User B: Changes title to "Project Beta"
// Conflict: Which title should be kept?

2. Optimistic Update Conflicts

Client-side optimistic updates that fail server validation:
// Client optimistically updates task status
model.optimistic({
  mutationId: 'update-123',
  name: 'task.updated',
  data: { status: 'completed' }
});

// Server rejects due to business logic
// (e.g., task has unmet dependencies)

3. Ordering Conflicts

Events arriving out of order due to network conditions:
// Events published: Delete -> Update
// Events received: Update -> Delete
// Conflict: Processing order matters

4. State Divergence

Client state drifting from server state due to missed updates:
// Client disconnects
// Server processes updates: A, B, C
// Client reconnects but only receives: A, C
// Conflict: Missing event B

Conflict Resolution Strategies

Last-Write-Wins (LWW)

The most recent update overwrites previous changes:
function merge(state, event) {
  const existingItem = state.items.find(i => i.id === event.data.id);
  
  if (!existingItem) {
    // New item, add it
    state.items.push(event.data);
  } else {
    // Compare timestamps
    if (event.data.updatedAt > existingItem.updatedAt) {
      // Newer update wins
      Object.assign(existingItem, event.data);
    }
    // Otherwise, ignore older update
  }
  
  return state;
}
Pros:
  • Simple to implement
  • No user intervention needed
  • Works well for single-field updates
Cons:
  • Can lose data
  • Not suitable for complex objects
  • No merge of concurrent changes

Field-Level Merging

Merge changes at the field level rather than object level:
function merge(state, event) {
  const item = state.items.find(i => i.id === event.data.id);
  
  if (!item) {
    state.items.push(event.data);
    return state;
  }
  
  // Merge field by field
  Object.keys(event.data.changes).forEach(field => {
    const newValue = event.data.changes[field];
    const currentValue = item[field];
    
    // Compare timestamps per field
    if (!item._timestamps?.[field] || 
        event.data.timestamp > item._timestamps[field]) {
      item[field] = newValue;
      item._timestamps = item._timestamps || {};
      item._timestamps[field] = event.data.timestamp;
    }
  });
  
  return state;
}
Example:
// User A updates: { title: "New Title", timestamp: 100 }
// User B updates: { status: "done", timestamp: 101 }
// Result: { title: "New Title", status: "done" }
Pros:
  • Preserves more changes
  • Better for multi-field objects
  • Reduces data loss
Cons:
  • More complex implementation
  • Requires field-level tracking
  • May create inconsistent states

Operational Transformation (OT)

Transform operations to maintain consistency:
// Example: Collaborative text editing
function transformOperations(op1, op2) {
  // If op1 inserts "Hello" at position 0
  // And op2 inserts "World" at position 0
  // Transform op2 to insert at position 5 (after "Hello")
  
  if (op1.type === 'insert' && op2.type === 'insert') {
    if (op2.position >= op1.position) {
      op2.position += op1.text.length;
    }
  }
  
  return [op1, op2];
}

function merge(state, event) {
  const transformed = transformAgainstHistory(
    event.data.operations,
    state.operationHistory
  );
  
  applyOperations(state, transformed);
  state.operationHistory.push(transformed);
  
  return state;
}
Pros:
  • Ideal for collaborative editing
  • Strong consistency guarantees
  • Preserves user intent
Cons:
  • Complex implementation
  • Requires operation history
  • Higher computational cost

Conflict-Free Replicated Data Types (CRDTs)

Use data structures that automatically resolve conflicts:
import { LWWMap } from 'yjs'; // Example CRDT library

function merge(state, event) {
  // Use CRDT for automatic conflict resolution
  const crdt = new LWWMap(state.data);
  
  event.data.changes.forEach(change => {
    crdt.set(change.key, change.value, change.timestamp);
  });
  
  return {
    ...state,
    data: crdt.toJSON()
  };
}
Common CRDT Types:
  • LWW-Element-Set: Last-write-wins for sets
  • OR-Set: Observed-remove set
  • G-Counter: Grow-only counter
  • PN-Counter: Positive-negative counter
Pros:
  • Automatic conflict resolution
  • Strong consistency
  • No coordination needed
Cons:
  • Limited data structure types
  • Memory overhead
  • Learning curve

Server-Authoritative Resolution

Let the server be the single source of truth:
// Client side: Only display confirmed updates
model.subscribe(
  (err, state) => {
    // Only confirmed changes from server
    updateUI(state);
  },
  { optimistic: false } // Disable optimistic updates
);

// Server side: Validate and resolve conflicts
app.post('/api/tasks/:id', async (req, res) => {
  const { id } = req.params;
  const { changes, expectedVersion } = req.body;
  
  await db.transaction(async (tx) => {
    // Lock the row for update
    const task = await tx.query(
      'SELECT * FROM tasks WHERE id = $1 FOR UPDATE',
      [id]
    );
    
    // Check version
    if (task.version !== expectedVersion) {
      throw new Error('Conflict: Task was modified');
    }
    
    // Apply changes and increment version
    await tx.query(`
      UPDATE tasks 
      SET status = $1, version = version + 1
      WHERE id = $2
    `, [changes.status, id]);
    
    // Publish to outbox
    await tx.query(`
      INSERT INTO outbox (channel, name, data)
      VALUES ($1, $2, $3)
    `, [`tasks:${id}`, 'task.updated', JSON.stringify(changes)]);
  });
  
  res.json({ success: true });
});
Pros:
  • Simple client logic
  • Server controls business rules
  • Clear consistency model
Cons:
  • Higher latency for updates
  • No optimistic updates
  • Server bottleneck

Optimistic Update Patterns

Optimistic with Rollback

Apply updates immediately, rollback on failure:
async function updateTask(taskId, status) {
  const mutationId = crypto.randomUUID();
  
  // Apply optimistically
  const [confirmation, cancel] = await model.optimistic({
    mutationId,
    name: 'task.updated',
    data: { id: taskId, status }
  });
  
  try {
    // Send to server
    await fetch(`/api/tasks/${taskId}`, {
      method: 'PATCH',
      body: JSON.stringify({ mutationId, status })
    });
    
    // Wait for confirmation
    await confirmation;
    
  } catch (error) {
    // Rollback on error
    cancel();
    
    // Notify user
    showError('Failed to update task');
  }
}

Optimistic with Reconciliation

Merge optimistic state with confirmed state:
function merge(state, event) {
  if (event.type === 'confirmed') {
    // Server confirmed update
    const optimisticEvent = state.pendingEvents.find(
      e => e.mutationId === event.mutationId
    );
    
    if (optimisticEvent) {
      // Compare optimistic vs confirmed
      if (JSON.stringify(optimisticEvent.data) !== 
          JSON.stringify(event.data)) {
        // Server made changes, use server version
        console.log('Server modified update:', event.data);
      }
      
      // Remove from pending
      state.pendingEvents = state.pendingEvents.filter(
        e => e.mutationId !== event.mutationId
      );
    }
    
    // Apply confirmed update
    applyUpdate(state, event.data);
    
  } else if (event.type === 'optimistic') {
    // Track pending
    state.pendingEvents.push(event);
    applyUpdate(state, event.data);
  }
  
  return state;
}

Optimistic with Retry

Retry failed optimistic updates:
async function updateWithRetry(taskId, changes, maxRetries = 3) {
  let attempt = 0;
  
  while (attempt < maxRetries) {
    try {
      const mutationId = crypto.randomUUID();
      
      const [confirmation, cancel] = await model.optimistic({
        mutationId,
        name: 'task.updated',
        data: { id: taskId, ...changes }
      });
      
      await fetch(`/api/tasks/${taskId}`, {
        method: 'PATCH',
        body: JSON.stringify({ mutationId, ...changes })
      });
      
      await confirmation;
      return; // Success
      
    } catch (error) {
      attempt++;
      
      if (attempt >= maxRetries) {
        throw new Error('Max retries exceeded');
      }
      
      // Exponential backoff
      await new Promise(resolve => 
        setTimeout(resolve, Math.pow(2, attempt) * 1000)
      );
    }
  }
}

Version Control Strategies

Optimistic Locking

Use version numbers to detect conflicts:
// Client side
async function updateDocument(docId, changes, currentVersion) {
  const response = await fetch(`/api/documents/${docId}`, {
    method: 'PATCH',
    body: JSON.stringify({
      changes,
      expectedVersion: currentVersion
    })
  });
  
  if (response.status === 409) {
    // Conflict detected
    const { latestVersion, latestData } = await response.json();
    
    // Show conflict resolution UI
    showConflictDialog({
      local: changes,
      remote: latestData,
      onResolve: (resolved) => {
        updateDocument(docId, resolved, latestVersion);
      }
    });
  }
}

// Server side
app.patch('/api/documents/:id', async (req, res) => {
  const { id } = req.params;
  const { changes, expectedVersion } = req.body;
  
  await db.transaction(async (tx) => {
    const doc = await tx.query(
      'SELECT * FROM documents WHERE id = $1 FOR UPDATE',
      [id]
    );
    
    if (doc.version !== expectedVersion) {
      // Version mismatch - conflict!
      return res.status(409).json({
        error: 'Conflict',
        latestVersion: doc.version,
        latestData: doc.data
      });
    }
    
    // Update with new version
    await tx.query(`
      UPDATE documents 
      SET data = $1, version = $2
      WHERE id = $3
    `, [changes, expectedVersion + 1, id]);
    
    // Publish to outbox
    await tx.query(`
      INSERT INTO outbox (channel, name, data)
      VALUES ($1, $2, $3)
    `, [`docs:${id}`, 'doc.updated', JSON.stringify(changes)]);
  });
  
  res.json({ success: true });
});

Vector Clocks

Track causality across distributed updates:
class VectorClock {
  constructor() {
    this.clock = {};
  }
  
  increment(nodeId) {
    this.clock[nodeId] = (this.clock[nodeId] || 0) + 1;
  }
  
  merge(other) {
    Object.keys(other.clock).forEach(nodeId => {
      this.clock[nodeId] = Math.max(
        this.clock[nodeId] || 0,
        other.clock[nodeId]
      );
    });
  }
  
  compare(other) {
    let greater = false;
    let less = false;
    
    const allNodes = new Set([
      ...Object.keys(this.clock),
      ...Object.keys(other.clock)
    ]);
    
    for (const node of allNodes) {
      const thisValue = this.clock[node] || 0;
      const otherValue = other.clock[node] || 0;
      
      if (thisValue > otherValue) greater = true;
      if (thisValue < otherValue) less = true;
    }
    
    if (greater && !less) return 1;   // this > other
    if (less && !greater) return -1;  // this < other
    if (greater && less) return 0;    // concurrent
    return 2;                          // equal
  }
}

function merge(state, event) {
  const eventClock = new VectorClock();
  eventClock.clock = event.vectorClock;
  
  const stateClock = new VectorClock();
  stateClock.clock = state.vectorClock;
  
  const comparison = stateClock.compare(eventClock);
  
  if (comparison === -1) {
    // Event is newer, apply it
    applyUpdate(state, event.data);
    stateClock.merge(eventClock);
    state.vectorClock = stateClock.clock;
  } else if (comparison === 0) {
    // Concurrent updates - conflict!
    handleConflict(state, event);
  }
  // comparison === 1: Event is older, ignore
  
  return state;
}

Conflict Detection

Monitoring Conflicts

const conflictMetrics = {
  total: 0,
  resolved: 0,
  manual: 0
};

function merge(state, event) {
  const conflict = detectConflict(state, event);
  
  if (conflict) {
    conflictMetrics.total++;
    
    // Log conflict for analysis
    logConflict({
      type: conflict.type,
      resource: event.data.id,
      timestamp: Date.now(),
      details: conflict.details
    });
    
    // Attempt automatic resolution
    const resolved = resolveConflict(state, event, conflict);
    
    if (resolved) {
      conflictMetrics.resolved++;
      return resolved;
    }
    
    // Require manual resolution
    conflictMetrics.manual++;
    showConflictUI(state, event, conflict);
  }
  
  return state;
}

function detectConflict(state, event) {
  const item = state.items.find(i => i.id === event.data.id);
  
  if (!item) return null;
  
  // Check for concurrent modifications
  if (event.data.version !== item.version + 1) {
    return {
      type: 'version-mismatch',
      expected: item.version + 1,
      received: event.data.version
    };
  }
  
  // Check for pending optimistic updates
  const pending = state.pendingEvents.find(
    e => e.data.id === event.data.id
  );
  
  if (pending) {
    return {
      type: 'optimistic-conflict',
      optimistic: pending.data,
      confirmed: event.data
    };
  }
  
  return null;
}

User Notification

function showConflictDialog(conflict) {
  const dialog = {
    title: 'Conflicting Changes Detected',
    message: `This ${conflict.resourceType} was modified by another user.`,
    options: [
      {
        label: 'Keep My Changes',
        value: 'local',
        description: 'Overwrite remote changes with yours'
      },
      {
        label: 'Use Their Changes',
        value: 'remote',
        description: 'Discard your changes'
      },
      {
        label: 'Merge Changes',
        value: 'merge',
        description: 'Combine both sets of changes'
      },
      {
        label: 'Review Differences',
        value: 'review',
        description: 'See detailed comparison'
      }
    ],
    onResolve: (choice) => {
      switch (choice) {
        case 'local':
          forceUpdate(conflict.local);
          break;
        case 'remote':
          acceptRemote(conflict.remote);
          break;
        case 'merge':
          showMergeEditor(conflict);
          break;
        case 'review':
          showDiffViewer(conflict);
          break;
      }
    }
  };
  
  showDialog(dialog);
}

Best Practices

1. Choose the Right Strategy

// For simple data: Last-write-wins
const simpleData = {
  status: 'active',
  priority: 'high'
};

// For complex objects: Field-level merging
const complexData = {
  title: 'Project',
  description: 'Long description...',
  metadata: { /* ... */ }
};

// For collaborative editing: Operational Transformation
const collaborativeText = {
  content: 'Document content...',
  operations: [ /* ... */ ]
};

2. Implement Proper Validation

app.patch('/api/resources/:id', async (req, res) => {
  const { id } = req.params;
  const { changes, mutationId } = req.body;
  
  try {
    // Validate changes
    const errors = validateChanges(changes);
    if (errors.length > 0) {
      // Reject optimistic update
      await rejectMutation(mutationId, errors);
      return res.status(400).json({ errors });
    }
    
    // Apply changes
    await applyChanges(id, changes, mutationId);
    res.json({ success: true });
    
  } catch (error) {
    await rejectMutation(mutationId, [error.message]);
    res.status(500).json({ error: error.message });
  }
});

async function rejectMutation(mutationId, errors) {
  await db.query(`
    INSERT INTO outbox (mutation_id, channel, name, rejected, data)
    VALUES ($1, $2, $3, true, $4)
  `, [mutationId, 'errors', 'update.rejected', JSON.stringify(errors)]);
}

3. Test Conflict Scenarios

describe('Conflict Resolution', () => {
  it('should handle concurrent updates', async () => {
    const model = createModel();
    
    // Simulate concurrent updates
    const update1 = { id: 1, title: 'Title A', timestamp: 100 };
    const update2 = { id: 1, title: 'Title B', timestamp: 101 };
    
    const state1 = merge(model.state, { data: update1 });
    const state2 = merge(state1, { data: update2 });
    
    // Verify resolution
    expect(state2.items[0].title).toBe('Title B'); // Later wins
  });
  
  it('should rollback failed optimistic updates', async () => {
    const model = createModel();
    
    // Apply optimistic update
    const [confirmation, cancel] = await model.optimistic({
      mutationId: 'test-123',
      data: { status: 'done' }
    });
    
    // Simulate rejection
    cancel();
    
    // Verify rollback
    expect(model.state.items[0].status).toBe('pending');
  });
});

4. Monitor and Analyze

// Track conflict metrics
const metrics = {
  conflicts: {
    total: 0,
    autoResolved: 0,
    manualResolved: 0,
    byType: {}
  }
};

function recordConflict(type, resolution) {
  metrics.conflicts.total++;
  metrics.conflicts.byType[type] = 
    (metrics.conflicts.byType[type] || 0) + 1;
  
  if (resolution === 'auto') {
    metrics.conflicts.autoResolved++;
  } else if (resolution === 'manual') {
    metrics.conflicts.manualResolved++;
  }
  
  // Send to analytics
  analytics.track('conflict', { type, resolution });
}

5. Document Behavior

/**
 * Merges incoming change events with existing state.
 * 
 * Conflict Resolution Strategy:
 * - Uses last-write-wins for simple field updates
 * - Requires manual resolution for complex objects
 * - Automatically rolls back rejected optimistic updates
 * 
 * @param {Object} state - Current model state
 * @param {Object} event - Incoming change event
 * @returns {Object} Updated state
 */
function merge(state, event) {
  // Implementation...
}

Next Steps

Database Sync

Learn more about database synchronization patterns

Models SDK

Explore the Models SDK for advanced conflict handling

Quickstart

Build a realtime application with LiveSync

Build docs developers (and LLMs) love