When CRDTs Collide: Resolving Sync Conflicts and State Divergence in Local-First Apps
{"title": "When CRDTs Collide: Resolving Sync Conflicts and State Divergence in Local-First Apps", "summary": "A deep dive into advanced Yjs local-first architectures, exploring manual conflict resolution UI, tombstone cleanup, and handling non-commutative business logic collisions.
{“title”: “When CRDTs Collide: Resolving Sync Conflicts and State Divergence in Local-First Apps”, “summary”: “A deep dive into advanced Yjs local-first architectures, exploring manual conflict resolution UI, tombstone cleanup, and handling non-commutative business logic collisions.”, “tags”: [“Distributed Systems”, “Frontend”, “Offline-First”, “Architecture”], “body”: “# When CRDTs Collide: Resolving Sync Conflicts and State Divergence in Local-First Apps\n\nLocal-first software promises a utopian user experience: instant responsiveness, seamless offline functionality, and absolute ownership of data. By leveraging Conflict-free Replicated Data Types (CRDTs), developers can synchronize state across multiple clients without requiring a central coordinator locking rows or files.\n\nFrameworks like Yjs have made building local-first applications remarkably accessible. Under the hood, Yjs uses a shared data type model powered by state-based and operation-based CRDT principles, utilizing local vector clocks and transaction identifiers to merge disparate histories automatically.\n\nHowever, anyone who has pushed a complex local-first application to production knows the dirty little secret of distributed systems: automatic merge does not mean semantic correctness.\n\nWhen two users offline-edit the same entity with contradictory business logic, a CRDT will happily merge the bytes. But the resulting state might be a logical disaster—such as a user being simultaneously assigned to two exclusive roles, or an inventory count dropping below zero because two offline clients deducted stock based on the same initial snapshot.\n\nIn this deep dive, we will explore the edge cases where automatic Yjs conflict resolution falls short, how to implement custom state tracking, manage tombstone bloat, and build an intuitive manual conflict-resolution UI for your users.\n\n—\n\n## The Limits of Automatic Convergence\n\nCRDTs guarantee strong eventual consistency (SEC). Given the same set of operations, all replicas will eventually arrive at the exact same byte state. In Yjs, this convergence is achieved through a combination of structural tree placements and Last-Write-Wins (LWW) or Multi-Value Register semantics for primitive types.\n\nConsider a collaborative document editor where a project’s status field is modeled as a Yjs Map:\n\njavascript\nimport * as Y from 'yjs';\n\nconst doc = new Y.Doc();\nconst project = doc.getMap('project');\n\n// Client A sets status while offline\nproject.set('status', 'In Review');\n\n// Client B sets status to something else while offline\nproject.set('status', 'Approved');\n\n\nWhen Clients A and B reconnect, Yjs resolves this collision using internal timestamps and client IDs to determine a deterministic winner. One of those updates will silently overwrite the other. \n\nIf Client B’s update wins due to a higher client ID tie-breaker, Client A’s state is overwritten without warning. For text documents, this is fine. For stateful business entities—like financial transactions, user permissions, or conflicting configuration flags—silent data loss or semantic corruption is unacceptable.\n\n### When Commutativity Fails Business Logic\n\nMath dictates that CRDT operations must be commutative ($A + B = B + A$) and associative. But real-world business logic is rarely commutative:\n\n1. Exclusive States: A user can only be assigned to one primary team. Two offline users assign different primary teams.\n2. Dependent Quantities: Item stock is decremented. Two users buy the last item while offline.\n3. Sequential Workflows: An item must move from Draft to Review to Published. An offline user jumps straight from Draft to Published while another edits the metadata in Review.\n\nTo handle these scenarios, we must detect when structural convergence has created a semantic divergence and provide pathways for manual or programmatic intervention.\n\n—n\n## Extending Yjs: Custom Vector Clocks and State Tracking\n\nWhile Yjs maintains an internal vector clock for syncing document updates (doc.store.clients), it doesn’t expose a clean API for tracking application-level causal milestones or branching histories.\n\nTo build a robust conflict-detection layer, we can embed metadata directly inside our Yjs documents, tracking structural revisions and authoring contexts.\n\n### Designing a Versioned Entity Schema\n\nInstead of storing raw properties in a flat Yjs Map, we wrap our entities in a structure that tracks the base version it was derived from, alongside the current modifications.\n\njavascript\n// Helper to initialize a version-tracked Y.Map entity\nfunction createVersionedEntity(doc, entityId, initialData) {\n const entities = doc.getMap('entities');\n const entity = new Y.Map();\n \n entity.set('id', entityId);\n entity.set('baseVector', getYjsVectorClock(doc));\n entity.set('data', new Y.Map(Object.entries(initialData)));\n entity.set('conflicts', new Y.Array());\n \n entities.set(entityId, entity);\n return entity;\n}\n\nfunction getYjsVectorClock(doc) {\n // Extract current client clock states from Yjs transaction history\n const clockObj = {};\n doc.store.clients.forEach((clock, clientId) => {\n clockObj[clientId] = clock;\n );\n return clockObj;\n}\n\n\nBy capturing the baseVector at the moment a client reads an entity to edit it, we can compare it against the global document state when synchronization occurs. If the global state has advanced significantly and concurrent updates have touched the same fields, we flag a potential collision.\n\n—n\n## Handling State Divergence and Tombstone Bloat\n\nAs local-first applications run over months or years, Yjs documents accumulate history. Every deletion in Yjs creates a tombstone—a marker ensuring that deleted items aren’t resurrected when syncing with a peer that hasn’t seen the deletion yet.\n\n### The Cost of Immortality\n\nLeft unchecked, tombstones and operation logs cause memory footprints to balloon, slowing down startup time and diff calculations. However, aggressively garbage-collecting Yjs history can break offline sync if a peer reconnects after a long absence.\n\nTo mitigate this in production systems, implement a snapshot-and-truncate strategy:\n\n1. State Snapshots: Periodically serialize the current immutable state of the Yjs document to durable local storage (IndexedDB).\n2. Epoch Cutoffs: Establish a sliding window (e.g., 30 days). Updates older than the cutoff are compacted into a single baseline snapshot.\n3. Peer Rejection Thresholds: If a peer has been offline longer than the cutoff window, force them to perform a state bootstrap (download the latest snapshot) rather than replaying historical diffs.\n\njavascript\n// Example of compacting state into a new baseline snapshot\nasync function createSnapshot(doc, persistenceProvider) {\n const stateVector = Y.encodeStateVector(doc);\n const update = Y.encodeStateAsUpdate(doc);\n \n await persistenceProvider.saveSnapshot({\n timestamp: Date.now(),\n stateVector,\n update,\n });\n \n // Note: True log truncation requires careful coordination in multi-user topologies\n // to ensure no active offline client relies on the discarded history slice.\n}\n\n\n—n\n## Building a Manual Conflict-Resolution UI\n\nWhen semantic divergence occurs, algorithms can only guess. The ultimate arbiter must be the user. Building an effective conflict-resolution UI requires catching divergences before they corrupt business logic, persisting them into a dedicated "Conflicts" state tree, and presenting a Git-style three-way merge interface.\n\n### Step 1: Conflict Detection Middleware\n\nRun a check during inbound sync events or transaction completions to evaluate if concurrent edits violated business invariants.\n\njavascript\nfunction detectAndFlagConflicts(doc, entityId) {\n const entities = doc.getMap('entities');\n const entity = entities.get(entityId);\n if (!entity) return;\n\n const baseVector = entity.get('baseVector');\n const currentVector = getYjsVectorClock(doc);\n \n // Check if other clients have written to this entity since our baseVector\n const hasConcurrentEdits = Object.entries(currentVector).some(([clientId, clock]) => {\n const baseClock = baseVector[clientId] || 0;\n return clientId !== doc.clientID && clock > baseClock;\n });\n\n if (hasConcurrentEdits && entity.get('isEditing')) {\n markEntityAsConflicted(entity);\n }\n}\n\n\n### Step 2: The Three-Way Merge UI Component\n\nWhen a document contains unresolved conflicts, surface a non-blocking notification banner. Clicking it opens a modal designed around the classic three-way merge layout:\n\n* Theirs (Incoming): The state submitted by the concurrent peer.\n* Yours (Local): The modifications made while offline.\n* Base (CommonAncestor): The state when editing originally began.\n\nHere is a conceptual React component implementation for resolving a field-level conflict in a local-first app:\n\ntsx\nimport React, { useState } from 'react';\n\ninterface ConflictModalProps {\n conflict: {\n id: string;\n field: string;\n localValue: any;\n remoteValue: any;\n baseValue: any;\n };\n onResolve: (field: string, chosenValue: any) => void;\n onDismiss: () => void;\n}\n\nexport const ConflictResolutionModal: React.FC<ConflictModalProps> = ({\n conflict,\n onResolve,\n onDismiss,\n})\ => {\n const [selected, setSelected] = useState<'local' | 'remote' | 'custom'>('local');\n const [customValue, setCustomValue] = useState(conflict.localValue);\n\n const handleAccept = () => {\n const val =\n selected === 'local'\n ? conflict.localValue\n : selected === 'remote'\n ? conflict.remoteValue\n : customValue;\n\n onResolve(conflict.field, val);\n onDismiss();\n };\n\n return (\n <div className=\"conflict-modal-overlay\">\n <div className=\"conflict-modal-card\">\n <h2>State Divergence Detected</h2>\n <p>Another user modified <code>{conflict.field}</code> while you were offline.</p>\n \n <div className=\"merge-grid\">\n <div \n className={`pane ${selected === 'local' ? 'active' : ''}`}\n onClick={() => setSelected('local')}\n >\n <h3>Your Version (Local)</h3>\n <pre>{JSON.stringify(conflict.localValue, null, 2)}</pre>\n </div>\n\n <div \n className={`pane ${selected === 'remote' ? 'active' : ''}`}\n onClick={() => setSelected('remote')}\n >\n <h3>Their Version (Remote)</h3>\n <pre>{JSON.stringify(conflict.remoteValue, null, 2)}</pre>\n </div>\n </div>\n\n <div className=\"modal-actions\">\n <button onClick={onDismiss}>Resolve Later</button>\n <button className=\"primary\" onClick={handleAccept}>Apply Resolution</button>\n </div>\n </div>\n </div>\n );\n};\n\n\n### Step 3: Applying the Resolution Transactionally\n\nOnce the user makes their choice, wrap the resolution inside an explicit Yjs transaction to ensure atomicity across all connected peers:\n\njavascript\nfunction resolveConflict(doc, entityId, fieldName, resolvedValue) {\n doc.transact(() => {\n const entities = doc.getMap('entities');\n const entity = entities.get(entityId);\n \n if (!entity) return;\n\n const dataMap = entity.get('data');\n dataMap.set(fieldName, resolvedValue);\n\n // Clear the conflict flag and reset the base vector\n entity.set('conflicts', new Y.Array());\n entity.set('baseVector', getYjsVectorClock(doc));\n }, 'conflict-resolution');\n}\n\n\nBy executing this update within doc.transact(), Yjs broadcasts a crisp, atomic change event that immediately updates all active UI bindings and synchronizes the resolved state down to connected peers.\n\n—n\n## Conclusion\n\nLocal-first architecture and CRDTs like Yjs provide an incredible foundation for resilient, lightning-fast applications. However, treating CRDTs as a silver bullet for state management inevitably leads to subtle, silent bugs when business logic collisions occur.\n\nBy combining:\n1. Explicit version tracking via custom metadata wrappers,\n2. Disciplined tombstone and snapshot management to prevent bloat, and\n3. Human-in-the-loop merge interfaces for semantic conflicts,\n\nYou can build local-first software that is not only lightning-fast and offline-capable, but also completely safe against the chaotic realities of distributed networks.”}