{"schemaVersion":1,"id":"https://lucioduran.com/blog/crdts-local-first-software-architecture","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture","title":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","description":"Deep dive into CRDTs with Yjs and Automerge 2.0. Operation-based vs state-based approaches, conflict resolution strategies, and real patterns for building collaborative applications that work offline.","language":"en","datePublished":"2025-12-16","dateModified":null,"contentFormat":"text/markdown","content":"Distributed Systems\n\n# CRDTs for Local-First Software: Building Collaborative Apps Without Servers\n\n2025-12-1610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/crdts-local-first-software-architecture>)\n\n## CRDTs from first principles\n\nBefore examining [Yjs](<https://yjs.dev/>) and [Automerge](<https://automerge.org/>), it is worth understanding the underlying mechanics, since most CRDT tutorials skip the theory and the merging behavior becomes difficult to reason about.\n\nA CRDT guarantees that if two replicas have received the same set of operations (in any order), they'll converge to the same state. There are two families:\n\n**State-based CRDTs (CvRDTs)**: Replicas send their full state to each other and merge using a function that's commutative, associative, and idempotent. Think of a G-Counter:\n\n```javascript\n// G-Counter: each node maintains its own counter\n// State-based, you merge by taking the max of each node's count\nclass GCounter {\n constructor(nodeId) {\n this.nodeId = nodeId;\n this.counts = {}; // { nodeId: count }\n }\n\n increment() {\n this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;\n }\n\n value() {\n return Object.values(this.counts).reduce((sum, c) => sum + c, 0);\n }\n\n merge(other) {\n // Take the max for each node, this is the magic\n // Commutative: merge(A,B) = merge(B,A)\n // Associative: merge(merge(A,B),C) = merge(A,merge(B,C))\n // Idempotent: merge(A,A) = A\n const merged = new GCounter(this.nodeId);\n const allNodes = new Set([\n ...Object.keys(this.counts),\n ...Object.keys(other.counts)\n ]);\n for (const node of allNodes) {\n merged.counts[node] = Math.max(\n this.counts[node] || 0,\n other.counts[node] || 0\n );\n }\n return merged;\n }\n}\n```\n\n**Operation-based CRDTs (CmRDTs)**: Replicas send operations to each other. Operations must be commutative (can be applied in any order). This is what Yjs and Automerge actually use, because sending \"insert character 'a' at position 3\" is way cheaper than sending the entire document.\n\nThe tricky part with op-based CRDTs for sequences (like text) is defining what \"position 3\" means when two users are typing simultaneously. This is where things get really interesting.\n\n## Yjs: the performance king\n\nYjs uses a modified YATA (Yet Another Transformation Approach) algorithm for its sequence types. Every character gets a unique ID based on the client ID and a logical clock:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\n\n// Create a Yjs document\nconst ydoc = new Y.Doc();\n\n// Shared types, these are the CRDTs\nconst ytext = ydoc.getText('editor');\nconst ymap = ydoc.getMap('metadata');\nconst yarray = ydoc.getArray('comments');\n\n// Connect to other peers\nconst provider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'document-abc-123',\n ydoc\n);\n\n// Observe changes from any source (local or remote)\nytext.observe((event) => {\n event.delta.forEach((op) => {\n if (op.insert) console.log('Inserted:', op.insert);\n if (op.delete) console.log('Deleted:', op.delete, 'chars');\n if (op.retain) console.log('Retained:', op.retain, 'chars');\n });\n});\n\n// Make changes inside a transaction\nydoc.transact(() => {\n ytext.insert(0, 'Hello, ');\n ytext.insert(7, 'world!');\n ymap.set('lastEditor', 'lucio');\n ymap.set('updatedAt', Date.now());\n});\n```\n\nWhat makes Yjs fast is its internal representation. Instead of storing each character as a separate node in a linked list (which is what the YATA paper describes), Yjs uses a technique called \"struct optimization\", consecutive characters from the same user are stored as a single struct:\n\n```\n// Instead of:\n[{id: {client:1, clock:0}, content: 'H'},\n {id: {client:1, clock:1}, content: 'e'},\n {id: {client:1, clock:2}, content: 'l'},\n {id: {client:1, clock:3}, content: 'l'},\n {id: {client:1, clock:4}, content: 'o'}]\n\n// Yjs stores:\n[{id: {client:1, clock:0}, content: 'Hello', length: 5}]\n```\n\nThis single optimization makes Yjs orders of magnitude faster than naive CRDT implementations. Kevin Jahns (Yjs's author) published benchmarks showing Yjs processing 260,000 edits in 0.5 seconds. I've verified similar numbers in my own testing.\n\n### Building a real collaborative editor with Yjs\n\nHere's a more complete example showing how to wire Yjs into a production editor:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\nimport { IndexeddbPersistence } from 'y-indexeddb';\nimport { Awareness } from 'y-protocols/awareness';\n\nconst ydoc = new Y.Doc();\n\n// Layer 1: Local persistence, works offline\nconst localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);\nlocalProvider.on('synced', () => {\n console.log('Loaded from local IndexedDB');\n});\n\n// Layer 2: Network sync, when available\nconst wsProvider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'doc-abc-123',\n ydoc,\n { connect: navigator.onLine }\n);\n\n// Handle online/offline\nwindow.addEventListener('online', () => wsProvider.connect());\nwindow.addEventListener('offline', () => wsProvider.disconnect());\n\n// Layer 3: Awareness, ephemeral presence data\nconst awareness = wsProvider.awareness;\nawareness.setLocalStateField('user', {\n name: 'Lucio',\n color: '#d97757',\n cursor: null,\n});\n\n// Listen for other users' presence\nawareness.on('change', () => {\n const states = awareness.getStates();\n states.forEach((state, clientId) => {\n if (clientId !== ydoc.clientID) {\n renderRemoteCursor(state.user);\n }\n });\n});\n\n// Undo/Redo with proper CRDT semantics\nconst undoManager = new Y.UndoManager(ydoc.getText('editor'), {\n trackedOrigins: new Set([null]), // Only track local changes\n captureTimeout: 500, // Group rapid edits\n});\n```\n\nThe three-layer architecture (IndexedDB + WebSocket + Awareness) is the pattern I've settled on after trying a dozen different approaches. IndexedDB gives you instant load and offline support. WebSocket gives you real-time sync. Awareness gives you presence without polluting the document history.\n\n## Automerge 2.0: the structured data champion\n\nWhile Yjs dominates in text editing, Automerge 2.0 brings a different philosophy. It treats the document as a JSON-like tree with full change history, every modification is tracked, and you can \"time travel\" through the document's evolution.\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\n// Create a document\nlet doc = Automerge.init();\n\n// Make changes\ndoc = Automerge.change(doc, 'Add initial task', (d) => {\n d.tasks = [];\n d.tasks.push({\n id: 'task-1',\n title: 'Implement CRDT sync',\n status: 'in_progress',\n assignee: 'lucio',\n subtasks: [\n { title: 'Research Yjs vs Automerge', done: true },\n { title: 'Build prototype', done: false },\n ],\n });\n});\n\n// Simulate concurrent edits from two peers\nlet doc1 = Automerge.clone(doc);\nlet doc2 = Automerge.clone(doc);\n\n// Peer 1: mark task as done\ndoc1 = Automerge.change(doc1, 'Complete task', (d) => {\n d.tasks[0].status = 'done';\n});\n\n// Peer 2: add a subtask (concurrently)\ndoc2 = Automerge.change(doc2, 'Add subtask', (d) => {\n d.tasks[0].subtasks.push({\n title: 'Write blog post about CRDTs',\n done: false,\n });\n});\n\n// Merge, both changes are preserved, no conflicts\nlet merged = Automerge.merge(doc1, doc2);\nconsole.log(merged.tasks[0].status); // 'done'\nconsole.log(merged.tasks[0].subtasks.length); // 3\n```\n\nAutomerge 2.0's killer feature is its binary format. The internal storage uses a columnar encoding that's incredibly compact:\n\n```javascript\n// Save to binary format\nconst binary = Automerge.save(doc);\nconsole.log(binary.length); // ~200 bytes for this document\n\n// vs JSON\nconst json = JSON.stringify(Automerge.toJS(doc));\nconsole.log(json.length); // ~350 bytes\n\n// The difference gets dramatic with history\n// A document with 10,000 changes:\n// Binary: ~50KB (stores full history)\n// JSON snapshot: ~5KB (no history)\n// JSON with history: ~2MB+\n```\n\n### Automerge sync protocol\n\nAutomerge has a built-in sync protocol that's remarkably elegant. Each peer tracks which changes it has and which changes it thinks the other peer has:\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\nclass SyncConnection {\n constructor(doc, sendFn) {\n this.doc = doc;\n this.send = sendFn;\n this.syncState = Automerge.initSyncState();\n }\n\n // Generate a sync message to send to the peer\n generateSyncMessage() {\n const [newSyncState, message] = Automerge.generateSyncMessage(\n this.doc,\n this.syncState\n );\n this.syncState = newSyncState;\n if (message) {\n this.send(message);\n }\n }\n\n // Receive a sync message from the peer\n receiveSyncMessage(message) {\n const [newDoc, newSyncState] = Automerge.receiveSyncMessage(\n this.doc,\n this.syncState,\n message\n );\n this.doc = newDoc;\n this.syncState = newSyncState;\n // After receiving, generate a response\n this.generateSyncMessage();\n }\n}\n```\n\nThe sync protocol figures out the minimal set of changes to exchange. If two peers are mostly in sync, the messages are tiny. If one peer has been offline for a week, the first message will be larger but still only contains the delta.\n\n## The conflict resolution reality\n\nHere's something that took me a while to internalize: CRDTs don't eliminate conflicts, they make conflict resolution deterministic and automatic. But \"automatic\" doesn't always mean \"correct from a user's perspective.\"\n\nConsider two users editing the same list item simultaneously:\n\n```javascript\n// User A: rename task\ndoc_A = Automerge.change(doc_A, (d) => {\n d.tasks[0].title = 'Build CRDT sync engine';\n});\n\n// User B: also rename the same task\ndoc_B = Automerge.change(doc_B, (d) => {\n d.tasks[0].title = 'Implement real-time sync';\n});\n\n// After merge: one wins based on actor ID ordering\n// The \"losing\" edit is not lost, it's in the history\nlet merged = Automerge.merge(doc_A, doc_B);\n// merged.tasks[0].title is deterministically one of the two values\n```\n\nFor scalar values (strings, numbers), CRDTs use \"last writer wins\" with a deterministic tiebreaker. This is usually fine, but sometimes you need application-level conflict handling:\n\n```javascript\n// Detect conflicts in Automerge\nconst conflicts = Automerge.getConflicts(merged.tasks[0], 'title');\nif (conflicts) {\n // Show UI to let user choose\n console.log('Conflicting values:', Object.values(conflicts));\n}\n```\n\nIn Yjs, you handle this differently, the Y.Map type doesn't expose conflicts for primitive values (LWW wins silently), but Y.Text uses character-level interleaving that usually produces reasonable results for concurrent edits.\n\nMy approach: use CRDTs for the sync layer but implement domain-specific merge strategies on top. For a kanban board, concurrent moves to different columns should probably result in the most recent move winning. For a collaborative document, concurrent edits to the same paragraph should be interleaved. For a shopping list, concurrent additions should both appear.\n\n## Architecture patterns for local-first apps\n\nAfter building several local-first applications, I've converged on this architecture:\n\n```\n┌─────────────────────────────────────────────┐\n│ Client │\n│ ┌─────────────┐ ┌──────────────────────┐ │\n│ │ UI Layer │ │ CRDT Document │ │\n│ │ (React) │◄─┤ (Yjs / Automerge) │ │\n│ └─────────────┘ └──────┬───────────────┘ │\n│ │ │\n│ ┌───────────────────────┼────────────────┐ │\n│ │ Persistence Layer │ │ │\n│ │ ┌─────────┐ ┌──────┴──────┐ │ │\n│ │ │ IndexedDB│ │ Sync Engine │ │ │\n│ │ │ (offline)│ │ (WebSocket) │ │ │\n│ │ └─────────┘ └─────────────┘ │ │\n│ └────────────────────────────────────────┘ │\n└─────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────┐\n│ Sync Server (optional, stateless) │\n│ - Relays updates between peers │\n│ - Stores snapshots for new peers │\n│ - Auth/access control │\n│ - Does NOT resolve conflicts │\n└─────────────────────────────────────────────┘\n```\n\nThe sync server is deliberately thin. It doesn't understand the document format, it just relays binary messages between peers and stores the latest snapshot for bootstrapping new clients. This means you can use [Cloudflare](<https://cloudflare.com/>) Workers, a simple WebSocket server, or even a peer-to-peer protocol like WebRTC.\n\n```javascript\n// Minimal sync server with y-websocket\nconst WebSocket = require('ws');\nconst { setupWSConnection } = require('y-websocket/bin/utils');\n\nconst wss = new WebSocket.Server({ port: 1234 });\n\nwss.on('connection', (ws, req) => {\n const docName = req.url.slice(1); // /document-id -> document-id\n\n // y-websocket handles all the sync protocol details\n setupWSConnection(ws, req, {\n docName,\n gc: true, // Enable garbage collection\n });\n});\n```\n\nThat's the entire server. Fifteen lines. The complexity lives in the CRDT library, which runs on the client.\n\n## Performance reality check\n\nLet me be honest about the trade-offs. CRDTs are not free:\n\n**Memory**: A Yjs document with 100,000 characters of text and full history uses ~2MB of memory. The equivalent plain string is 100KB. That 20x overhead is the cost of tracking every operation for conflict resolution.\n\n**Document size**: Automerge documents grow with history. A document that's been heavily edited over months can reach 10-50MB in binary format. You need compaction strategies:\n\n```javascript\n// Automerge compaction, squash history\nconst compacted = Automerge.clone(doc); // Cloning discards detailed history\n// Or save with specific heads to trim\n```\n\n**Initial sync**: When a new peer joins, it needs the full document. For large documents, this can take seconds. Use snapshot + incremental updates:\n\n```javascript\n// Server stores periodic snapshots\nconst snapshot = Automerge.save(doc);\n// New peer loads snapshot, then applies incremental updates\nlet peerDoc = Automerge.load(snapshot);\nfor (const update of incrementalUpdates) {\n [peerDoc] = Automerge.receiveSyncMessage(peerDoc, syncState, update);\n}\n```\n\nDespite these trade-offs, for applications where real-time collaboration and offline support matter, CRDTs are the only approach that doesn't require you to build a custom conflict resolution system from scratch. The CRDT library authors have thought about edge cases you haven't even imagined yet.\n\nThe local-first movement is still early, but the tools are finally production-ready. Pick Yjs for real-time collaborative editing, Automerge for structured data with rich history, and build your sync layer to be as thin as possible. The less your server knows about your data, the more resilient your architecture becomes.\n\n## Frequently asked questions\n\n### What is a CRDT and why should I care?\n\nA Conflict-free Replicated Data Type is a data structure that can be modified independently on multiple devices and always merged into a consistent state without conflicts. It enables real-time collaboration and offline-first apps without complex server-side conflict resolution.\n\n### Yjs or Automerge, which should I pick?\n\nYjs for collaborative text editing and real-time features where performance matters most. Automerge 2.0 for structured document data where you want a richer change history and time-travel capabilities. Both are production-ready.\n\n### Can CRDTs replace a traditional database?\n\nNot entirely. CRDTs handle conflict resolution for concurrent edits brilliantly, but they don't provide queries, indexing, access control, or server-side validation. Use CRDTs for the collaboration layer and a traditional database for the system of record.\n\n## Tips\n\n1. **Tip:** Use Yjs for text-heavy collaborative editing, its ` Y.Text ` type uses a YATA-based CRDT that handles concurrent inserts better than Automerge's sequence type.\n2. **Tip:** Automerge 2.0's binary format is ~10x smaller than JSON for document storage. Use ` Automerge.save() ` for persistence, not ` JSON.stringify() `.\n3. **Tip:** Always implement ` gc: true ` in Yjs providers for long-lived documents, without it, the internal deletion tombstones grow unbounded and will eat your memory.\n4. **Tip:** For presence features (cursors, selections), don't use the CRDT document, use an ephemeral awareness protocol like ` y-protocols/awareness ` instead.\n\n- crdts\n- local-first\n- yjs\n- automerge\n- distributed-systems\n- collaboration\n- offline-first\n- conflict-resolution\n- real-time\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#title"},{"level":2,"text":"CRDTs from first principles","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#crdts-from-first-principles"},{"level":2,"text":"Yjs: the performance king","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#yjs-the-performance-king"},{"level":3,"text":"Building a real collaborative editor with Yjs","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#building-a-real-collaborative-editor-with-yjs"},{"level":2,"text":"Automerge 2.0: the structured data champion","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#automerge-20-the-structured-data-champion"},{"level":3,"text":"Automerge sync protocol","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#automerge-sync-protocol"},{"level":2,"text":"The conflict resolution reality","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#the-conflict-resolution-reality"},{"level":2,"text":"Architecture patterns for local-first apps","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#architecture-patterns-for-local-first-apps"},{"level":2,"text":"Performance reality check","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#performance-reality-check"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#questions"},{"level":3,"text":"What is a CRDT and why should I care?","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-1"},{"level":3,"text":"Yjs or Automerge, which should I pick?","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-2"},{"level":3,"text":"Can CRDTs replace a traditional database?","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#article-tips"}],"sections":[{"heading":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","level":1,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture","content":"Distributed Systems"},{"heading":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","level":1,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#title","content":"2025-12-1610 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/crdts-local-first-software-architecture>)"},{"heading":"CRDTs from first principles","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#crdts-from-first-principles","content":"Before examining [Yjs](<https://yjs.dev/>) and [Automerge](<https://automerge.org/>), it is worth understanding the underlying mechanics, since most CRDT tutorials skip the theory and the merging behavior becomes difficult to reason about.\n\nA CRDT guarantees that if two replicas have received the same set of operations (in any order), they'll converge to the same state. There are two families:\n\n**State-based CRDTs (CvRDTs)**: Replicas send their full state to each other and merge using a function that's commutative, associative, and idempotent. Think of a G-Counter:\n\n```javascript\n// G-Counter: each node maintains its own counter\n// State-based, you merge by taking the max of each node's count\nclass GCounter {\n constructor(nodeId) {\n this.nodeId = nodeId;\n this.counts = {}; // { nodeId: count }\n }\n\n increment() {\n this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;\n }\n\n value() {\n return Object.values(this.counts).reduce((sum, c) => sum + c, 0);\n }\n\n merge(other) {\n // Take the max for each node, this is the magic\n // Commutative: merge(A,B) = merge(B,A)\n // Associative: merge(merge(A,B),C) = merge(A,merge(B,C))\n // Idempotent: merge(A,A) = A\n const merged = new GCounter(this.nodeId);\n const allNodes = new Set([\n ...Object.keys(this.counts),\n ...Object.keys(other.counts)\n ]);\n for (const node of allNodes) {\n merged.counts[node] = Math.max(\n this.counts[node] || 0,\n other.counts[node] || 0\n );\n }\n return merged;\n }\n}\n```\n\n**Operation-based CRDTs (CmRDTs)**: Replicas send operations to each other. Operations must be commutative (can be applied in any order). This is what Yjs and Automerge actually use, because sending \"insert character 'a' at position 3\" is way cheaper than sending the entire document.\n\nThe tricky part with op-based CRDTs for sequences (like text) is defining what \"position 3\" means when two users are typing simultaneously. This is where things get really interesting."},{"heading":"Yjs: the performance king","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#yjs-the-performance-king","content":"Yjs uses a modified YATA (Yet Another Transformation Approach) algorithm for its sequence types. Every character gets a unique ID based on the client ID and a logical clock:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\n\n// Create a Yjs document\nconst ydoc = new Y.Doc();\n\n// Shared types, these are the CRDTs\nconst ytext = ydoc.getText('editor');\nconst ymap = ydoc.getMap('metadata');\nconst yarray = ydoc.getArray('comments');\n\n// Connect to other peers\nconst provider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'document-abc-123',\n ydoc\n);\n\n// Observe changes from any source (local or remote)\nytext.observe((event) => {\n event.delta.forEach((op) => {\n if (op.insert) console.log('Inserted:', op.insert);\n if (op.delete) console.log('Deleted:', op.delete, 'chars');\n if (op.retain) console.log('Retained:', op.retain, 'chars');\n });\n});\n\n// Make changes inside a transaction\nydoc.transact(() => {\n ytext.insert(0, 'Hello, ');\n ytext.insert(7, 'world!');\n ymap.set('lastEditor', 'lucio');\n ymap.set('updatedAt', Date.now());\n});\n```\n\nWhat makes Yjs fast is its internal representation. Instead of storing each character as a separate node in a linked list (which is what the YATA paper describes), Yjs uses a technique called \"struct optimization\", consecutive characters from the same user are stored as a single struct:\n\n```\n// Instead of:\n[{id: {client:1, clock:0}, content: 'H'},\n {id: {client:1, clock:1}, content: 'e'},\n {id: {client:1, clock:2}, content: 'l'},\n {id: {client:1, clock:3}, content: 'l'},\n {id: {client:1, clock:4}, content: 'o'}]\n\n// Yjs stores:\n[{id: {client:1, clock:0}, content: 'Hello', length: 5}]\n```\n\nThis single optimization makes Yjs orders of magnitude faster than naive CRDT implementations. Kevin Jahns (Yjs's author) published benchmarks showing Yjs processing 260,000 edits in 0.5 seconds. I've verified similar numbers in my own testing."},{"heading":"Building a real collaborative editor with Yjs","level":3,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#building-a-real-collaborative-editor-with-yjs","content":"Here's a more complete example showing how to wire Yjs into a production editor:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\nimport { IndexeddbPersistence } from 'y-indexeddb';\nimport { Awareness } from 'y-protocols/awareness';\n\nconst ydoc = new Y.Doc();\n\n// Layer 1: Local persistence, works offline\nconst localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);\nlocalProvider.on('synced', () => {\n console.log('Loaded from local IndexedDB');\n});\n\n// Layer 2: Network sync, when available\nconst wsProvider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'doc-abc-123',\n ydoc,\n { connect: navigator.onLine }\n);\n\n// Handle online/offline\nwindow.addEventListener('online', () => wsProvider.connect());\nwindow.addEventListener('offline', () => wsProvider.disconnect());\n\n// Layer 3: Awareness, ephemeral presence data\nconst awareness = wsProvider.awareness;\nawareness.setLocalStateField('user', {\n name: 'Lucio',\n color: '#d97757',\n cursor: null,\n});\n\n// Listen for other users' presence\nawareness.on('change', () => {\n const states = awareness.getStates();\n states.forEach((state, clientId) => {\n if (clientId !== ydoc.clientID) {\n renderRemoteCursor(state.user);\n }\n });\n});\n\n// Undo/Redo with proper CRDT semantics\nconst undoManager = new Y.UndoManager(ydoc.getText('editor'), {\n trackedOrigins: new Set([null]), // Only track local changes\n captureTimeout: 500, // Group rapid edits\n});\n```\n\nThe three-layer architecture (IndexedDB + WebSocket + Awareness) is the pattern I've settled on after trying a dozen different approaches. IndexedDB gives you instant load and offline support. WebSocket gives you real-time sync. Awareness gives you presence without polluting the document history."},{"heading":"Automerge 2.0: the structured data champion","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#automerge-20-the-structured-data-champion","content":"While Yjs dominates in text editing, Automerge 2.0 brings a different philosophy. It treats the document as a JSON-like tree with full change history, every modification is tracked, and you can \"time travel\" through the document's evolution.\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\n// Create a document\nlet doc = Automerge.init();\n\n// Make changes\ndoc = Automerge.change(doc, 'Add initial task', (d) => {\n d.tasks = [];\n d.tasks.push({\n id: 'task-1',\n title: 'Implement CRDT sync',\n status: 'in_progress',\n assignee: 'lucio',\n subtasks: [\n { title: 'Research Yjs vs Automerge', done: true },\n { title: 'Build prototype', done: false },\n ],\n });\n});\n\n// Simulate concurrent edits from two peers\nlet doc1 = Automerge.clone(doc);\nlet doc2 = Automerge.clone(doc);\n\n// Peer 1: mark task as done\ndoc1 = Automerge.change(doc1, 'Complete task', (d) => {\n d.tasks[0].status = 'done';\n});\n\n// Peer 2: add a subtask (concurrently)\ndoc2 = Automerge.change(doc2, 'Add subtask', (d) => {\n d.tasks[0].subtasks.push({\n title: 'Write blog post about CRDTs',\n done: false,\n });\n});\n\n// Merge, both changes are preserved, no conflicts\nlet merged = Automerge.merge(doc1, doc2);\nconsole.log(merged.tasks[0].status); // 'done'\nconsole.log(merged.tasks[0].subtasks.length); // 3\n```\n\nAutomerge 2.0's killer feature is its binary format. The internal storage uses a columnar encoding that's incredibly compact:\n\n```javascript\n// Save to binary format\nconst binary = Automerge.save(doc);\nconsole.log(binary.length); // ~200 bytes for this document\n\n// vs JSON\nconst json = JSON.stringify(Automerge.toJS(doc));\nconsole.log(json.length); // ~350 bytes\n\n// The difference gets dramatic with history\n// A document with 10,000 changes:\n// Binary: ~50KB (stores full history)\n// JSON snapshot: ~5KB (no history)\n// JSON with history: ~2MB+\n```"},{"heading":"Automerge sync protocol","level":3,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#automerge-sync-protocol","content":"Automerge has a built-in sync protocol that's remarkably elegant. Each peer tracks which changes it has and which changes it thinks the other peer has:\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\nclass SyncConnection {\n constructor(doc, sendFn) {\n this.doc = doc;\n this.send = sendFn;\n this.syncState = Automerge.initSyncState();\n }\n\n // Generate a sync message to send to the peer\n generateSyncMessage() {\n const [newSyncState, message] = Automerge.generateSyncMessage(\n this.doc,\n this.syncState\n );\n this.syncState = newSyncState;\n if (message) {\n this.send(message);\n }\n }\n\n // Receive a sync message from the peer\n receiveSyncMessage(message) {\n const [newDoc, newSyncState] = Automerge.receiveSyncMessage(\n this.doc,\n this.syncState,\n message\n );\n this.doc = newDoc;\n this.syncState = newSyncState;\n // After receiving, generate a response\n this.generateSyncMessage();\n }\n}\n```\n\nThe sync protocol figures out the minimal set of changes to exchange. If two peers are mostly in sync, the messages are tiny. If one peer has been offline for a week, the first message will be larger but still only contains the delta."},{"heading":"The conflict resolution reality","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#the-conflict-resolution-reality","content":"Here's something that took me a while to internalize: CRDTs don't eliminate conflicts, they make conflict resolution deterministic and automatic. But \"automatic\" doesn't always mean \"correct from a user's perspective.\"\n\nConsider two users editing the same list item simultaneously:\n\n```javascript\n// User A: rename task\ndoc_A = Automerge.change(doc_A, (d) => {\n d.tasks[0].title = 'Build CRDT sync engine';\n});\n\n// User B: also rename the same task\ndoc_B = Automerge.change(doc_B, (d) => {\n d.tasks[0].title = 'Implement real-time sync';\n});\n\n// After merge: one wins based on actor ID ordering\n// The \"losing\" edit is not lost, it's in the history\nlet merged = Automerge.merge(doc_A, doc_B);\n// merged.tasks[0].title is deterministically one of the two values\n```\n\nFor scalar values (strings, numbers), CRDTs use \"last writer wins\" with a deterministic tiebreaker. This is usually fine, but sometimes you need application-level conflict handling:\n\n```javascript\n// Detect conflicts in Automerge\nconst conflicts = Automerge.getConflicts(merged.tasks[0], 'title');\nif (conflicts) {\n // Show UI to let user choose\n console.log('Conflicting values:', Object.values(conflicts));\n}\n```\n\nIn Yjs, you handle this differently, the Y.Map type doesn't expose conflicts for primitive values (LWW wins silently), but Y.Text uses character-level interleaving that usually produces reasonable results for concurrent edits.\n\nMy approach: use CRDTs for the sync layer but implement domain-specific merge strategies on top. For a kanban board, concurrent moves to different columns should probably result in the most recent move winning. For a collaborative document, concurrent edits to the same paragraph should be interleaved. For a shopping list, concurrent additions should both appear."},{"heading":"Architecture patterns for local-first apps","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#architecture-patterns-for-local-first-apps","content":"After building several local-first applications, I've converged on this architecture:\n\n```\n┌─────────────────────────────────────────────┐\n│ Client │\n│ ┌─────────────┐ ┌──────────────────────┐ │\n│ │ UI Layer │ │ CRDT Document │ │\n│ │ (React) │◄─┤ (Yjs / Automerge) │ │\n│ └─────────────┘ └──────┬───────────────┘ │\n│ │ │\n│ ┌───────────────────────┼────────────────┐ │\n│ │ Persistence Layer │ │ │\n│ │ ┌─────────┐ ┌──────┴──────┐ │ │\n│ │ │ IndexedDB│ │ Sync Engine │ │ │\n│ │ │ (offline)│ │ (WebSocket) │ │ │\n│ │ └─────────┘ └─────────────┘ │ │\n│ └────────────────────────────────────────┘ │\n└─────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────┐\n│ Sync Server (optional, stateless) │\n│ - Relays updates between peers │\n│ - Stores snapshots for new peers │\n│ - Auth/access control │\n│ - Does NOT resolve conflicts │\n└─────────────────────────────────────────────┘\n```\n\nThe sync server is deliberately thin. It doesn't understand the document format, it just relays binary messages between peers and stores the latest snapshot for bootstrapping new clients. This means you can use [Cloudflare](<https://cloudflare.com/>) Workers, a simple WebSocket server, or even a peer-to-peer protocol like WebRTC.\n\n```javascript\n// Minimal sync server with y-websocket\nconst WebSocket = require('ws');\nconst { setupWSConnection } = require('y-websocket/bin/utils');\n\nconst wss = new WebSocket.Server({ port: 1234 });\n\nwss.on('connection', (ws, req) => {\n const docName = req.url.slice(1); // /document-id -> document-id\n\n // y-websocket handles all the sync protocol details\n setupWSConnection(ws, req, {\n docName,\n gc: true, // Enable garbage collection\n });\n});\n```\n\nThat's the entire server. Fifteen lines. The complexity lives in the CRDT library, which runs on the client."},{"heading":"Performance reality check","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#performance-reality-check","content":"Let me be honest about the trade-offs. CRDTs are not free:\n\n**Memory**: A Yjs document with 100,000 characters of text and full history uses ~2MB of memory. The equivalent plain string is 100KB. That 20x overhead is the cost of tracking every operation for conflict resolution.\n\n**Document size**: Automerge documents grow with history. A document that's been heavily edited over months can reach 10-50MB in binary format. You need compaction strategies:\n\n```javascript\n// Automerge compaction, squash history\nconst compacted = Automerge.clone(doc); // Cloning discards detailed history\n// Or save with specific heads to trim\n```\n\n**Initial sync**: When a new peer joins, it needs the full document. For large documents, this can take seconds. Use snapshot + incremental updates:\n\n```javascript\n// Server stores periodic snapshots\nconst snapshot = Automerge.save(doc);\n// New peer loads snapshot, then applies incremental updates\nlet peerDoc = Automerge.load(snapshot);\nfor (const update of incrementalUpdates) {\n [peerDoc] = Automerge.receiveSyncMessage(peerDoc, syncState, update);\n}\n```\n\nDespite these trade-offs, for applications where real-time collaboration and offline support matter, CRDTs are the only approach that doesn't require you to build a custom conflict resolution system from scratch. The CRDT library authors have thought about edge cases you haven't even imagined yet.\n\nThe local-first movement is still early, but the tools are finally production-ready. Pick Yjs for real-time collaborative editing, Automerge for structured data with rich history, and build your sync layer to be as thin as possible. The less your server knows about your data, the more resilient your architecture becomes."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#questions","content":""},{"heading":"What is a CRDT and why should I care?","level":3,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-1","content":"A Conflict-free Replicated Data Type is a data structure that can be modified independently on multiple devices and always merged into a consistent state without conflicts. It enables real-time collaboration and offline-first apps without complex server-side conflict resolution."},{"heading":"Yjs or Automerge, which should I pick?","level":3,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-2","content":"Yjs for collaborative text editing and real-time features where performance matters most. Automerge 2.0 for structured document data where you want a richer change history and time-travel capabilities. Both are production-ready."},{"heading":"Can CRDTs replace a traditional database?","level":3,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#question-3","content":"Not entirely. CRDTs handle conflict resolution for concurrent edits brilliantly, but they don't provide queries, indexing, access control, or server-side validation. Use CRDTs for the collaboration layer and a traditional database for the system of record."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/crdts-local-first-software-architecture#article-tips","content":"1. **Tip:** Use Yjs for text-heavy collaborative editing, its ` Y.Text ` type uses a YATA-based CRDT that handles concurrent inserts better than Automerge's sequence type.\n2. **Tip:** Automerge 2.0's binary format is ~10x smaller than JSON for document storage. Use ` Automerge.save() ` for persistence, not ` JSON.stringify() `.\n3. **Tip:** Always implement ` gc: true ` in Yjs providers for long-lived documents, without it, the internal deletion tombstones grow unbounded and will eat your memory.\n4. **Tip:** For presence features (cursors, selections), don't use the CRDT document, use an ephemeral awareness protocol like ` y-protocols/awareness ` instead.\n\n- crdts\n- local-first\n- yjs\n- automerge\n- distributed-systems\n- collaboration\n- offline-first\n- conflict-resolution\n- real-time\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture"},{"text":"Yjs","url":"https://yjs.dev/"},{"text":"Automerge","url":"https://automerge.org/"},{"text":"Cloudflare","url":"https://cloudflare.com/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/crdts-local-first-software-architecture#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/crdts-local-first-software-architecture"},"headline":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","description":"Deep dive into CRDTs with Yjs and Automerge 2.0. Operation-based vs state-based approaches, conflict resolution strategies, and real patterns for building collaborative applications that work offline.","datePublished":"2025-12-16","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Distributed Systems","keywords":"crdts, local-first, yjs, automerge, distributed-systems, collaboration, offline-first, conflict-resolution, real-time","wordCount":1920,"timeRequired":"PT10M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"CRDTs for Local-First Software: Building Collaborative Apps Without Servers","item":"https://lucioduran.com/blog/crdts-local-first-software-architecture"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is a CRDT and why should I care?","acceptedAnswer":{"@type":"Answer","text":"A Conflict-free Replicated Data Type is a data structure that can be modified independently on multiple devices and always merged into a consistent state without conflicts. It enables real-time collaboration and offline-first apps without complex server-side conflict resolution."}},{"@type":"Question","name":"Yjs or Automerge, which should I pick?","acceptedAnswer":{"@type":"Answer","text":"Yjs for collaborative text editing and real-time features where performance matters most. Automerge 2.0 for structured document data where you want a richer change history and time-travel capabilities. Both are production-ready."}},{"@type":"Question","name":"Can CRDTs replace a traditional database?","acceptedAnswer":{"@type":"Answer","text":"Not entirely. CRDTs handle conflict resolution for concurrent edits brilliantly, but they don't provide queries, indexing, access control, or server-side validation. Use CRDTs for the collaboration layer and a traditional database for the system of record."}}]}]}],"contentHash":"2d1b1bb8d8bf8c6059a94af24d907429603410204bac3a0f0ee76ef86eaa1529","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/crdts-local-first-software-architecture","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":71496,"contentBytes":17205}}
