{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture","title":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","description":"Deep dive en CRDTs con Yjs y Automerge 2.0. Operation-based vs state-based, estrategias de resolución de conflictos y patrones reales para construir apps colaborativas que funcionan offline.","language":"es","datePublished":"2025-12-16","dateModified":null,"contentFormat":"text/markdown","content":"Distributed Systems\n\n# CRDTs para Software Local-First: Apps Colaborativas Sin Servidores\n\n2025-12-169 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/crdts-local-first-software-architecture>)\n\n## CRDTs desde primeros principios\n\nAntes de examinar [Yjs](<https://yjs.dev/>) y [Automerge](<https://automerge.org/>), es conveniente comprender los mecanismos subyacentes, ya que la mayoría de los tutoriales de CRDTs omiten la teoría y el comportamiento de fusión resulta difícil de razonar.\n\nUn CRDT garantiza que si dos réplicas recibieron el mismo set de operaciones (en cualquier orden), van a converger al mismo estado. Hay dos familias:\n\n**CRDTs basados en estado (CvRDTs)**: Las réplicas se mandan su estado completo entre sí y mergean usando una función que es conmutativa, asociativa e idempotente. Considerar en un G-Counter:\n\n```javascript\n// G-Counter: cada nodo mantiene su propio contador\n// Basado en estado, mergeás tomando el max del count de cada nodo\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 // Tomá el max para cada nodo, esta es la magia\n // Conmutativa: merge(A,B) = merge(B,A)\n // Asociativa: merge(merge(A,B),C) = merge(A,merge(B,C))\n // Idempotente: 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**CRDTs basados en operaciones (CmRDTs)**: Las réplicas se mandan operaciones entre sí. Las operaciones tienen que ser conmutativas (se pueden aplicar en cualquier orden). Esto es lo que Yjs y Automerge realmente usan, porque mandar \"insertar carácter 'a' en posición 3\" es mucho más barato que mandar el documento entero.\n\nLa parte complicada con CRDTs op-based para secuencias (como texto) es definir qué significa \"posición 3\" cuando dos usuarios están tipeando simultáneamente. Acá es donde la cosa resulta particularmente relevante.\n\n## Yjs: el rey del rendimiento\n\nYjs usa un algoritmo YATA (Yet Another Transformation Approach) modificado para sus tipos de secuencia. Cada carácter obtiene un ID único basado en el client ID y un reloj lógico:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\n\n// Crear un documento Yjs\nconst ydoc = new Y.Doc();\n\n// Tipos compartidos, estos son los CRDTs\nconst ytext = ydoc.getText('editor');\nconst ymap = ydoc.getMap('metadata');\nconst yarray = ydoc.getArray('comments');\n\n// Conectar con otros peers\nconst provider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'document-abc-123',\n ydoc\n);\n\n// Observar cambios de cualquier fuente (local o remota)\nytext.observe((event) => {\n event.delta.forEach((op) => {\n if (op.insert) console.log('Insertado:', op.insert);\n if (op.delete) console.log('Borrado:', op.delete, 'chars');\n if (op.retain) console.log('Retenido:', op.retain, 'chars');\n });\n});\n\n// Hacer cambios dentro de una transacción\nydoc.transact(() => {\n ytext.insert(0, 'Hola, ');\n ytext.insert(6, 'mundo!');\n ymap.set('lastEditor', 'lucio');\n ymap.set('updatedAt', Date.now());\n});\n```\n\nLo que hace rápido a Yjs es su representación interna. En vez de almacenar cada carácter como un nodo separado en una lista enlazada (que es lo que el paper de YATA describe), Yjs usa una técnica llamada \"struct optimization\", caracteres consecutivos del mismo usuario se guardan como un solo struct:\n\n```\n// En vez de:\n[{id: {client:1, clock:0}, content: 'H'},\n {id: {client:1, clock:1}, content: 'o'},\n {id: {client:1, clock:2}, content: 'l'},\n {id: {client:1, clock:3}, content: 'a'}]\n\n// Yjs guarda:\n[{id: {client:1, clock:0}, content: 'Hola', length: 4}]\n```\n\nEsta sola optimización hace que Yjs sea órdenes de magnitud más rápido que implementaciones CRDT naive. Kevin Jahns (el autor de Yjs) publicó benchmarks mostrando Yjs procesando 260,000 ediciones en 0.5 segundos. Yo verifiqué números similares en mis propios tests.\n\n### Construyendo un editor colaborativo real con Yjs\n\nAcá va un ejemplo más completo mostrando cómo cablear Yjs en un editor de producción:\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// Capa 1: Persistencia local, funciona offline\nconst localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);\nlocalProvider.on('synced', () => {\n console.log('Cargado desde IndexedDB local');\n});\n\n// Capa 2: Sync por red, cuando hay conexión\nconst wsProvider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'doc-abc-123',\n ydoc,\n { connect: navigator.onLine }\n);\n\n// Manejar online/offline\nwindow.addEventListener('online', () => wsProvider.connect());\nwindow.addEventListener('offline', () => wsProvider.disconnect());\n\n// Capa 3: Awareness, datos de presencia efímeros\nconst awareness = wsProvider.awareness;\nawareness.setLocalStateField('user', {\n name: 'Lucio',\n color: '#d97757',\n cursor: null,\n});\n\n// Escuchar la presencia de otros usuarios\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 con semánticas CRDT correctas\nconst undoManager = new Y.UndoManager(ydoc.getText('editor'), {\n trackedOrigins: new Set([null]), // Solo trackear cambios locales\n captureTimeout: 500, // Agrupar ediciones rápidas\n});\n```\n\nLa arquitectura de tres capas (IndexedDB + WebSocket + Awareness) es el patrón en el que convergí después de probar una docena de approaches diferentes. IndexedDB te da carga instantánea y soporte offline. WebSocket te da sync en tiempo real. Awareness te da presencia sin contaminar el historial del documento.\n\n## Automerge 2.0: el campeón de datos estructurados\n\nMientras Yjs domina en edición de texto, Automerge 2.0 trae una filosofía diferente. Trata al documento como un árbol tipo JSON con historial de cambios completo, cada modificación se trackea, y es posible \"viajar en el tiempo\" por la evolución del documento.\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\n// Crear un documento\nlet doc = Automerge.init();\n\n// Hacer cambios\ndoc = Automerge.change(doc, 'Agregar tarea inicial', (d) => {\n d.tasks = [];\n d.tasks.push({\n id: 'task-1',\n title: 'Implementar sync CRDT',\n status: 'in_progress',\n assignee: 'lucio',\n subtasks: [\n { title: 'Investigar Yjs vs Automerge', done: true },\n { title: 'Construir prototipo', done: false },\n ],\n });\n});\n\n// Simular ediciones concurrentes de dos peers\nlet doc1 = Automerge.clone(doc);\nlet doc2 = Automerge.clone(doc);\n\n// Peer 1: marcar tarea como terminada\ndoc1 = Automerge.change(doc1, 'Completar tarea', (d) => {\n d.tasks[0].status = 'done';\n});\n\n// Peer 2: agregar una subtarea (concurrentemente)\ndoc2 = Automerge.change(doc2, 'Agregar subtarea', (d) => {\n d.tasks[0].subtasks.push({\n title: 'Escribir blog post sobre CRDTs',\n done: false,\n });\n});\n\n// Merge, ambos cambios se preservan, sin conflictos\nlet merged = Automerge.merge(doc1, doc2);\nconsole.log(merged.tasks[0].status); // 'done'\nconsole.log(merged.tasks[0].subtasks.length); // 3\n```\n\nLa característica más destacada de Automerge 2.0 es su formato binario. El almacenamiento interno usa un encoding columnar que es increíblemente compacto:\n\n```javascript\n// Guardar en formato binario\nconst binary = Automerge.save(doc);\nconsole.log(binary.length); // ~200 bytes para este documento\n\n// vs JSON\nconst json = JSON.stringify(Automerge.toJS(doc));\nconsole.log(json.length); // ~350 bytes\n\n// La diferencia se hace dramática con historial\n// Un documento con 10,000 cambios:\n// Binario: ~50KB (guarda historial completo)\n// Snapshot JSON: ~5KB (sin historial)\n// JSON con historial: ~2MB+\n```\n\n## La realidad de la resolución de conflictos\n\nAcá hay algo que me costó internalizar: los CRDTs no eliminan conflictos, hacen que la resolución de conflictos sea determinista y automática. Pero \"automática\" no siempre significa \"correcta desde la perspectiva del usuario.\"\n\nConsiderá dos usuarios editando el mismo ítem de una lista simultáneamente:\n\n```javascript\n// Usuario A: renombrar tarea\ndoc_A = Automerge.change(doc_A, (d) => {\n d.tasks[0].title = 'Construir motor de sync CRDT';\n});\n\n// Usuario B: también renombra la misma tarea\ndoc_B = Automerge.change(doc_B, (d) => {\n d.tasks[0].title = 'Implementar sync real-time';\n});\n\n// Después del merge: uno gana basado en ordenamiento de actor ID\nlet merged = Automerge.merge(doc_A, doc_B);\n\n// Detectar conflictos en Automerge\nconst conflicts = Automerge.getConflicts(merged.tasks[0], 'title');\nif (conflicts) {\n console.log('Valores en conflicto:', Object.values(conflicts));\n}\n```\n\nEl enfoque recomendado es utilizar CRDTs para la capa de sync e implementar estrategias de merge específicas del dominio encima. Para un tablero kanban, movimientos concurrentes a distintas columnas probablemente deberían resultar en que gane el movimiento más reciente. Para un documento colaborativo, las ediciones concurrentes al mismo párrafo deberían intercalarse. Para una lista de compras, las adiciones concurrentes deberían aparecer ambas.\n\n## Patrones de arquitectura para apps local-first\n\nDespués de construir varias aplicaciones local-first, convergí en esta arquitectura:\n\n```\n┌─────────────────────────────────────────────┐\n│ Cliente │\n│ ┌─────────────┐ ┌──────────────────────┐ │\n│ │ Capa UI │ │ Documento CRDT │ │\n│ │ (React) │◄─┤ (Yjs / Automerge) │ │\n│ └─────────────┘ └──────┬───────────────┘ │\n│ │ │\n│ ┌───────────────────────┼────────────────┐ │\n│ │ Capa de Persistencia │ │ │\n│ │ ┌─────────┐ ┌──────┴──────┐ │ │\n│ │ │ IndexedDB│ │ Motor Sync │ │ │\n│ │ │ (offline)│ │ (WebSocket) │ │ │\n│ │ └─────────┘ └─────────────┘ │ │\n│ └────────────────────────────────────────┘ │\n└─────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────┐\n│ Servidor de Sync (opcional, stateless) │\n│ - Retransmite updates entre peers │\n│ - Guarda snapshots para peers nuevos │\n│ - Auth/control de acceso │\n│ - NO resuelve conflictos │\n└─────────────────────────────────────────────┘\n```\n\nEl servidor de sync es deliberadamente fino. No entiende el formato del documento, solo retransmite mensajes binarios entre peers y guarda el último snapshot para bootstrapear clientes nuevos.\n\n```javascript\n// Servidor de sync mínimo con 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);\n setupWSConnection(ws, req, {\n docName,\n gc: true,\n });\n});\n```\n\nEse es el servidor entero. Quince líneas. La complejidad vive en la librería CRDT, que corre en el cliente.\n\n## Reality check de rendimiento\n\nDéjenme ser honesto con los trade-offs. Los CRDTs no son gratis:\n\n**Memoria**: Un documento Yjs con 100,000 caracteres de texto e historial completo usa ~2MB de memoria. El string plano equivalente son 100KB. Ese overhead de 20x es el costo de trackear cada operación para resolución de conflictos.\n\n**Tamaño de documento**: Los documentos de Automerge crecen con el historial. Un documento que fue muy editado durante meses puede llegar a 10-50MB en formato binario. Necesitás estrategias de compactación.\n\n**Sync inicial**: Cuando un peer nuevo se une, necesita el documento completo. Para documentos grandes, esto puede tardar segundos.\n\nA pesar de estos trade-offs, para aplicaciones donde la colaboración en tiempo real y el soporte offline importan, los CRDTs son el único enfoque que no te obliga a construir un sistema de resolución de conflictos custom desde cero. Y créanme, intenté construir ese es sistemas custom. Los autores de las librerías CRDT pensaron en edge cases que ni te imaginás.\n\nEl movimiento local-first todavía está en etapa temprana, pero las herramientas finalmente están listas para producción. Elegir Yjs para edición colaborativa de texto en tiempo real, Automerge para datos estructurados con historial rico, y construí tu capa de sync lo más fina posible. Cuanto menos sepa tu servidor sobre tus datos, más resiliente se vuelve tu arquitectura.\n\n## Preguntas frecuentes\n\n### ¿Qué es un CRDT y por qué me debería importar?\n\nUn Tipo de Dato Replicado Libre de Conflictos es una estructura de datos que puede ser modificada independientemente en múltiples dispositivos y siempre se fusiona en un estado consistente sin conflictos. Permite colaboración en tiempo real y apps offline-first sin resolución de conflictos compleja del lado del servidor.\n\n### ¿Yjs o Automerge, cuál debería elegir?\n\nYjs para edición colaborativa de texto y features real-time donde el rendimiento importa más. Automerge 2.0 para datos de documentos estructurados donde se desea un historial de cambios más rico y capacidades de viaje en el tiempo. Ambos están listos para producción.\n\n### ¿Pueden los CRDTs reemplazar una base de datos tradicional?\n\nNo completamente. Los CRDTs manejan la resolución de conflictos para ediciones concurrentes de forma brillante, pero no proveen queries, indexación, control de acceso ni validación del lado del servidor. Utilizar CRDTs para la capa de colaboración y una base de datos tradicional como sistema de registro.\n\n## Tips\n\n1. **Tip:** Se recomienda utilizar Yjs para edición colaborativa de texto, su tipo ` Y.Text ` usa un CRDT basado en YATA que maneja inserciones concurrentes mejor que el tipo sequence de Automerge.\n2. **Tip:** El formato binario de Automerge 2.0 es ~10x más chico que JSON para almacenamiento de documentos. Utilizar ` Automerge.save() ` para persistir, no ` JSON.stringify() `.\n3. **Tip:** Se recomienda implementar siempre ` gc: true ` en los providers de Yjs para documentos de larga vida, sin eso, los tombstones de eliminación internos crecen sin límite y te van a comer la memoria.\n4. **Tip:** Para features de presencia (cursores, selecciones), no se debe utilizar el documento CRDT, conviene emplear un protocolo efímero como ` y-protocols/awareness `.\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[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#title"},{"level":2,"text":"CRDTs desde primeros principios","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#crdts-desde-primeros-principios"},{"level":2,"text":"Yjs: el rey del rendimiento","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#yjs-el-rey-del-rendimiento"},{"level":3,"text":"Construyendo un editor colaborativo real con Yjs","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#construyendo-un-editor-colaborativo-real-con-yjs"},{"level":2,"text":"Automerge 2.0: el campeón de datos estructurados","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#automerge-20-el-campen-de-datos-estructurados"},{"level":2,"text":"La realidad de la resolución de conflictos","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#la-realidad-de-la-resolucin-de-conflictos"},{"level":2,"text":"Patrones de arquitectura para apps local-first","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#patrones-de-arquitectura-para-apps-local-first"},{"level":2,"text":"Reality check de rendimiento","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#reality-check-de-rendimiento"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#questions"},{"level":3,"text":"¿Qué es un CRDT y por qué me debería importar?","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-1"},{"level":3,"text":"¿Yjs o Automerge, cuál debería elegir?","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-2"},{"level":3,"text":"¿Pueden los CRDTs reemplazar una base de datos tradicional?","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#article-tips"}],"sections":[{"heading":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","level":1,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture","content":"Distributed Systems"},{"heading":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","level":1,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#title","content":"2025-12-169 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/crdts-local-first-software-architecture>)"},{"heading":"CRDTs desde primeros principios","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#crdts-desde-primeros-principios","content":"Antes de examinar [Yjs](<https://yjs.dev/>) y [Automerge](<https://automerge.org/>), es conveniente comprender los mecanismos subyacentes, ya que la mayoría de los tutoriales de CRDTs omiten la teoría y el comportamiento de fusión resulta difícil de razonar.\n\nUn CRDT garantiza que si dos réplicas recibieron el mismo set de operaciones (en cualquier orden), van a converger al mismo estado. Hay dos familias:\n\n**CRDTs basados en estado (CvRDTs)**: Las réplicas se mandan su estado completo entre sí y mergean usando una función que es conmutativa, asociativa e idempotente. Considerar en un G-Counter:\n\n```javascript\n// G-Counter: cada nodo mantiene su propio contador\n// Basado en estado, mergeás tomando el max del count de cada nodo\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 // Tomá el max para cada nodo, esta es la magia\n // Conmutativa: merge(A,B) = merge(B,A)\n // Asociativa: merge(merge(A,B),C) = merge(A,merge(B,C))\n // Idempotente: 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**CRDTs basados en operaciones (CmRDTs)**: Las réplicas se mandan operaciones entre sí. Las operaciones tienen que ser conmutativas (se pueden aplicar en cualquier orden). Esto es lo que Yjs y Automerge realmente usan, porque mandar \"insertar carácter 'a' en posición 3\" es mucho más barato que mandar el documento entero.\n\nLa parte complicada con CRDTs op-based para secuencias (como texto) es definir qué significa \"posición 3\" cuando dos usuarios están tipeando simultáneamente. Acá es donde la cosa resulta particularmente relevante."},{"heading":"Yjs: el rey del rendimiento","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#yjs-el-rey-del-rendimiento","content":"Yjs usa un algoritmo YATA (Yet Another Transformation Approach) modificado para sus tipos de secuencia. Cada carácter obtiene un ID único basado en el client ID y un reloj lógico:\n\n```javascript\nimport * as Y from 'yjs';\nimport { WebsocketProvider } from 'y-websocket';\n\n// Crear un documento Yjs\nconst ydoc = new Y.Doc();\n\n// Tipos compartidos, estos son los CRDTs\nconst ytext = ydoc.getText('editor');\nconst ymap = ydoc.getMap('metadata');\nconst yarray = ydoc.getArray('comments');\n\n// Conectar con otros peers\nconst provider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'document-abc-123',\n ydoc\n);\n\n// Observar cambios de cualquier fuente (local o remota)\nytext.observe((event) => {\n event.delta.forEach((op) => {\n if (op.insert) console.log('Insertado:', op.insert);\n if (op.delete) console.log('Borrado:', op.delete, 'chars');\n if (op.retain) console.log('Retenido:', op.retain, 'chars');\n });\n});\n\n// Hacer cambios dentro de una transacción\nydoc.transact(() => {\n ytext.insert(0, 'Hola, ');\n ytext.insert(6, 'mundo!');\n ymap.set('lastEditor', 'lucio');\n ymap.set('updatedAt', Date.now());\n});\n```\n\nLo que hace rápido a Yjs es su representación interna. En vez de almacenar cada carácter como un nodo separado en una lista enlazada (que es lo que el paper de YATA describe), Yjs usa una técnica llamada \"struct optimization\", caracteres consecutivos del mismo usuario se guardan como un solo struct:\n\n```\n// En vez de:\n[{id: {client:1, clock:0}, content: 'H'},\n {id: {client:1, clock:1}, content: 'o'},\n {id: {client:1, clock:2}, content: 'l'},\n {id: {client:1, clock:3}, content: 'a'}]\n\n// Yjs guarda:\n[{id: {client:1, clock:0}, content: 'Hola', length: 4}]\n```\n\nEsta sola optimización hace que Yjs sea órdenes de magnitud más rápido que implementaciones CRDT naive. Kevin Jahns (el autor de Yjs) publicó benchmarks mostrando Yjs procesando 260,000 ediciones en 0.5 segundos. Yo verifiqué números similares en mis propios tests."},{"heading":"Construyendo un editor colaborativo real con Yjs","level":3,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#construyendo-un-editor-colaborativo-real-con-yjs","content":"Acá va un ejemplo más completo mostrando cómo cablear Yjs en un editor de producción:\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// Capa 1: Persistencia local, funciona offline\nconst localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);\nlocalProvider.on('synced', () => {\n console.log('Cargado desde IndexedDB local');\n});\n\n// Capa 2: Sync por red, cuando hay conexión\nconst wsProvider = new WebsocketProvider(\n 'wss://sync.example.com',\n 'doc-abc-123',\n ydoc,\n { connect: navigator.onLine }\n);\n\n// Manejar online/offline\nwindow.addEventListener('online', () => wsProvider.connect());\nwindow.addEventListener('offline', () => wsProvider.disconnect());\n\n// Capa 3: Awareness, datos de presencia efímeros\nconst awareness = wsProvider.awareness;\nawareness.setLocalStateField('user', {\n name: 'Lucio',\n color: '#d97757',\n cursor: null,\n});\n\n// Escuchar la presencia de otros usuarios\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 con semánticas CRDT correctas\nconst undoManager = new Y.UndoManager(ydoc.getText('editor'), {\n trackedOrigins: new Set([null]), // Solo trackear cambios locales\n captureTimeout: 500, // Agrupar ediciones rápidas\n});\n```\n\nLa arquitectura de tres capas (IndexedDB + WebSocket + Awareness) es el patrón en el que convergí después de probar una docena de approaches diferentes. IndexedDB te da carga instantánea y soporte offline. WebSocket te da sync en tiempo real. Awareness te da presencia sin contaminar el historial del documento."},{"heading":"Automerge 2.0: el campeón de datos estructurados","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#automerge-20-el-campen-de-datos-estructurados","content":"Mientras Yjs domina en edición de texto, Automerge 2.0 trae una filosofía diferente. Trata al documento como un árbol tipo JSON con historial de cambios completo, cada modificación se trackea, y es posible \"viajar en el tiempo\" por la evolución del documento.\n\n```javascript\nimport * as Automerge from '@automerge/automerge';\n\n// Crear un documento\nlet doc = Automerge.init();\n\n// Hacer cambios\ndoc = Automerge.change(doc, 'Agregar tarea inicial', (d) => {\n d.tasks = [];\n d.tasks.push({\n id: 'task-1',\n title: 'Implementar sync CRDT',\n status: 'in_progress',\n assignee: 'lucio',\n subtasks: [\n { title: 'Investigar Yjs vs Automerge', done: true },\n { title: 'Construir prototipo', done: false },\n ],\n });\n});\n\n// Simular ediciones concurrentes de dos peers\nlet doc1 = Automerge.clone(doc);\nlet doc2 = Automerge.clone(doc);\n\n// Peer 1: marcar tarea como terminada\ndoc1 = Automerge.change(doc1, 'Completar tarea', (d) => {\n d.tasks[0].status = 'done';\n});\n\n// Peer 2: agregar una subtarea (concurrentemente)\ndoc2 = Automerge.change(doc2, 'Agregar subtarea', (d) => {\n d.tasks[0].subtasks.push({\n title: 'Escribir blog post sobre CRDTs',\n done: false,\n });\n});\n\n// Merge, ambos cambios se preservan, sin conflictos\nlet merged = Automerge.merge(doc1, doc2);\nconsole.log(merged.tasks[0].status); // 'done'\nconsole.log(merged.tasks[0].subtasks.length); // 3\n```\n\nLa característica más destacada de Automerge 2.0 es su formato binario. El almacenamiento interno usa un encoding columnar que es increíblemente compacto:\n\n```javascript\n// Guardar en formato binario\nconst binary = Automerge.save(doc);\nconsole.log(binary.length); // ~200 bytes para este documento\n\n// vs JSON\nconst json = JSON.stringify(Automerge.toJS(doc));\nconsole.log(json.length); // ~350 bytes\n\n// La diferencia se hace dramática con historial\n// Un documento con 10,000 cambios:\n// Binario: ~50KB (guarda historial completo)\n// Snapshot JSON: ~5KB (sin historial)\n// JSON con historial: ~2MB+\n```"},{"heading":"La realidad de la resolución de conflictos","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#la-realidad-de-la-resolucin-de-conflictos","content":"Acá hay algo que me costó internalizar: los CRDTs no eliminan conflictos, hacen que la resolución de conflictos sea determinista y automática. Pero \"automática\" no siempre significa \"correcta desde la perspectiva del usuario.\"\n\nConsiderá dos usuarios editando el mismo ítem de una lista simultáneamente:\n\n```javascript\n// Usuario A: renombrar tarea\ndoc_A = Automerge.change(doc_A, (d) => {\n d.tasks[0].title = 'Construir motor de sync CRDT';\n});\n\n// Usuario B: también renombra la misma tarea\ndoc_B = Automerge.change(doc_B, (d) => {\n d.tasks[0].title = 'Implementar sync real-time';\n});\n\n// Después del merge: uno gana basado en ordenamiento de actor ID\nlet merged = Automerge.merge(doc_A, doc_B);\n\n// Detectar conflictos en Automerge\nconst conflicts = Automerge.getConflicts(merged.tasks[0], 'title');\nif (conflicts) {\n console.log('Valores en conflicto:', Object.values(conflicts));\n}\n```\n\nEl enfoque recomendado es utilizar CRDTs para la capa de sync e implementar estrategias de merge específicas del dominio encima. Para un tablero kanban, movimientos concurrentes a distintas columnas probablemente deberían resultar en que gane el movimiento más reciente. Para un documento colaborativo, las ediciones concurrentes al mismo párrafo deberían intercalarse. Para una lista de compras, las adiciones concurrentes deberían aparecer ambas."},{"heading":"Patrones de arquitectura para apps local-first","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#patrones-de-arquitectura-para-apps-local-first","content":"Después de construir varias aplicaciones local-first, convergí en esta arquitectura:\n\n```\n┌─────────────────────────────────────────────┐\n│ Cliente │\n│ ┌─────────────┐ ┌──────────────────────┐ │\n│ │ Capa UI │ │ Documento CRDT │ │\n│ │ (React) │◄─┤ (Yjs / Automerge) │ │\n│ └─────────────┘ └──────┬───────────────┘ │\n│ │ │\n│ ┌───────────────────────┼────────────────┐ │\n│ │ Capa de Persistencia │ │ │\n│ │ ┌─────────┐ ┌──────┴──────┐ │ │\n│ │ │ IndexedDB│ │ Motor Sync │ │ │\n│ │ │ (offline)│ │ (WebSocket) │ │ │\n│ │ └─────────┘ └─────────────┘ │ │\n│ └────────────────────────────────────────┘ │\n└─────────────────────────────────────────────┘\n │\n ▼\n┌─────────────────────────────────────────────┐\n│ Servidor de Sync (opcional, stateless) │\n│ - Retransmite updates entre peers │\n│ - Guarda snapshots para peers nuevos │\n│ - Auth/control de acceso │\n│ - NO resuelve conflictos │\n└─────────────────────────────────────────────┘\n```\n\nEl servidor de sync es deliberadamente fino. No entiende el formato del documento, solo retransmite mensajes binarios entre peers y guarda el último snapshot para bootstrapear clientes nuevos.\n\n```javascript\n// Servidor de sync mínimo con 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);\n setupWSConnection(ws, req, {\n docName,\n gc: true,\n });\n});\n```\n\nEse es el servidor entero. Quince líneas. La complejidad vive en la librería CRDT, que corre en el cliente."},{"heading":"Reality check de rendimiento","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#reality-check-de-rendimiento","content":"Déjenme ser honesto con los trade-offs. Los CRDTs no son gratis:\n\n**Memoria**: Un documento Yjs con 100,000 caracteres de texto e historial completo usa ~2MB de memoria. El string plano equivalente son 100KB. Ese overhead de 20x es el costo de trackear cada operación para resolución de conflictos.\n\n**Tamaño de documento**: Los documentos de Automerge crecen con el historial. Un documento que fue muy editado durante meses puede llegar a 10-50MB en formato binario. Necesitás estrategias de compactación.\n\n**Sync inicial**: Cuando un peer nuevo se une, necesita el documento completo. Para documentos grandes, esto puede tardar segundos.\n\nA pesar de estos trade-offs, para aplicaciones donde la colaboración en tiempo real y el soporte offline importan, los CRDTs son el único enfoque que no te obliga a construir un sistema de resolución de conflictos custom desde cero. Y créanme, intenté construir ese es sistemas custom. Los autores de las librerías CRDT pensaron en edge cases que ni te imaginás.\n\nEl movimiento local-first todavía está en etapa temprana, pero las herramientas finalmente están listas para producción. Elegir Yjs para edición colaborativa de texto en tiempo real, Automerge para datos estructurados con historial rico, y construí tu capa de sync lo más fina posible. Cuanto menos sepa tu servidor sobre tus datos, más resiliente se vuelve tu arquitectura."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#questions","content":""},{"heading":"¿Qué es un CRDT y por qué me debería importar?","level":3,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-1","content":"Un Tipo de Dato Replicado Libre de Conflictos es una estructura de datos que puede ser modificada independientemente en múltiples dispositivos y siempre se fusiona en un estado consistente sin conflictos. Permite colaboración en tiempo real y apps offline-first sin resolución de conflictos compleja del lado del servidor."},{"heading":"¿Yjs o Automerge, cuál debería elegir?","level":3,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-2","content":"Yjs para edición colaborativa de texto y features real-time donde el rendimiento importa más. Automerge 2.0 para datos de documentos estructurados donde se desea un historial de cambios más rico y capacidades de viaje en el tiempo. Ambos están listos para producción."},{"heading":"¿Pueden los CRDTs reemplazar una base de datos tradicional?","level":3,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#question-3","content":"No completamente. Los CRDTs manejan la resolución de conflictos para ediciones concurrentes de forma brillante, pero no proveen queries, indexación, control de acceso ni validación del lado del servidor. Utilizar CRDTs para la capa de colaboración y una base de datos tradicional como sistema de registro."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#article-tips","content":"1. **Tip:** Se recomienda utilizar Yjs para edición colaborativa de texto, su tipo ` Y.Text ` usa un CRDT basado en YATA que maneja inserciones concurrentes mejor que el tipo sequence de Automerge.\n2. **Tip:** El formato binario de Automerge 2.0 es ~10x más chico que JSON para almacenamiento de documentos. Utilizar ` Automerge.save() ` para persistir, no ` JSON.stringify() `.\n3. **Tip:** Se recomienda implementar siempre ` gc: true ` en los providers de Yjs para documentos de larga vida, sin eso, los tombstones de eliminación internos crecen sin límite y te van a comer la memoria.\n4. **Tip:** Para features de presencia (cursores, selecciones), no se debe utilizar el documento CRDT, conviene emplear un protocolo efímero como ` y-protocols/awareness `.\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[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture"},{"text":"Yjs","url":"https://yjs.dev/"},{"text":"Automerge","url":"https://automerge.org/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture"},"headline":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","description":"Deep dive en CRDTs con Yjs y Automerge 2.0. Operation-based vs state-based, estrategias de resolución de conflictos y patrones reales para construir apps colaborativas que funcionan 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://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture","inLanguage":"es","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":1689,"timeRequired":"PT9M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/crdts-local-first-software-architecture"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"CRDTs para Software Local-First: Apps Colaborativas Sin Servidores","item":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Qué es un CRDT y por qué me debería importar?","acceptedAnswer":{"@type":"Answer","text":"Un Tipo de Dato Replicado Libre de Conflictos es una estructura de datos que puede ser modificada independientemente en múltiples dispositivos y siempre se fusiona en un estado consistente sin conflictos. Permite colaboración en tiempo real y apps offline-first sin resolución de conflictos compleja del lado del servidor."}},{"@type":"Question","name":"¿Yjs o Automerge, cuál debería elegir?","acceptedAnswer":{"@type":"Answer","text":"Yjs para edición colaborativa de texto y features real-time donde el rendimiento importa más. Automerge 2.0 para datos de documentos estructurados donde se desea un historial de cambios más rico y capacidades de viaje en el tiempo. Ambos están listos para producción."}},{"@type":"Question","name":"¿Pueden los CRDTs reemplazar una base de datos tradicional?","acceptedAnswer":{"@type":"Answer","text":"No completamente. Los CRDTs manejan la resolución de conflictos para ediciones concurrentes de forma brillante, pero no proveen queries, indexación, control de acceso ni validación del lado del servidor. Utilizar CRDTs para la capa de colaboración y una base de datos tradicional como sistema de registro."}}]}]}],"contentHash":"1d3a4e0a548a539fff9a9b78f1f62716339f26c37b0e24afba0e6db13305b1a0","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/crdts-local-first-software-architecture","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":70735,"contentBytes":15723}}
