wandres.dev
CRDT II · estado, operaciones y deltas

Elegir en la práctica: transporte, fallos y librerías reales

Qué le exige cada familia a tu capa de red, qué ocurre exactamente cuando un mensaje se pierde o llega dos veces, y qué han acabado construyendo Yjs, Automerge y los almacenes distribuidos.

⏱ 20 min

Las cuatro lecciones anteriores dejaron el terreno preparado: dos familias con el mismo poder expresivo, un contrato de red radicalmente distinto en cada una y una técnica intermedia que traslada el punto de equilibrio. Falta lo que decide el resultado de un proyecto, que es traducir todo eso a preguntas que se puedan responder mirando la infraestructura que ya tienes. Qué transporte vas a usar y qué garantiza de verdad. Qué ocurre en tu sistema cuando un mensaje se pierde, cuando llega dos veces o cuando un cliente vuelve después de un mes. Y qué han acabado construyendo las librerías que sostienen sistemas en producción, que casi nunca eligen una familia pura y cuyas decisiones son la mejor guía disponible sobre dónde están los costes reales.

🎯 Al terminar esta lección sabrás
  • Traducir el contrato de cada familia a requisitos concretos sobre transportes habituales del navegador y del servidor.
  • Predecir el comportamiento del sistema ante pérdida, duplicación, reordenación y reconexión tardía.
  • Reconocer el diseño real de Yjs y de Automerge y por qué ninguno de los dos es una familia pura.
  • Disponer de un criterio de elección que se apoye en la infraestructura disponible y no en preferencias estéticas.

Lo que cada familia le pide a tu transporte

La pregunta operativa no es qué familia prefieres, es qué garantías te da de verdad el canal que vas a usar, porque la diferencia entre las dos familias es exactamente el conjunto de garantías que tendrás que construir tú si el canal no las trae. Y conviene mirar el canal con desconfianza: casi todos los transportes ofrecen menos de lo que su documentación sugiere en cuanto se introduce una reconexión.

Un canal de difusión entre pestañas del mismo navegador entrega en orden a quien está escuchando y no entrega nada a quien no lo estaba, de modo que una pestaña que se abre tarde se ha perdido todo lo anterior sin enterarse. Un socket persistente entrega en orden mientras dura la conexión y no dice nada sobre lo ocurrido durante el corte. Una cola de mensajes del lado servidor suele prometer al menos una vez, que significa duplicados garantizados y orden solo dentro de una partición. Y un almacén de objetos o una carpeta sincronizada no ofrece ni orden ni notificación, solo la posibilidad de leer el último contenido escrito.

🗂️

Almacén de objetos o carpeta

Sin orden, sin entrega, sin sesión. Solo estado o delta funcionan aquí, y funcionan sin escribir protocolo alguno.

📡

Difusión entre pestañas

Orden dentro de la sesión, cero historia para quien llega tarde. Exige una resincronización explícita al abrir.

🔌

Socket persistente

Orden mientras dura, silencio sobre el corte. La familia de operaciones necesita aquí un protocolo de recuperación completo.

📬

Cola con al menos una vez

Duplicados garantizados y orden parcial. Gratis para estado y delta, deduplicación obligatoria para operaciones.

Hay una asimetría en esa lista que conviene subrayar. Los transportes ricos —el socket con sesión, la cola ordenada por partición— son los que menos abundan en el escenario que a este track le interesa, que es el de dispositivos de usuario con conectividad intermitente. Los transportes pobres —la carpeta sincronizada, el almacén de objetos, el fichero exportado, el intercambio directo entre dos dispositivos que se encuentran en la misma red local— son exactamente los que un producto local-first acaba necesitando, porque son los únicos que siguen funcionando cuando el servicio central no está disponible. Diseñar para el transporte rico y esperar añadir los pobres después es el orden equivocado.

La regla que se deduce de la tabla es directa y ahorra mucho trabajo cuando se aplica temprano: si el transporte no tiene sesión, no elijas operaciones. Todo lo que una familia basada en operaciones necesita —fiabilidad, deduplicación, orden causal, recuperación tras hueco— es un sistema con estado por cada par de participantes, y montarlo encima de un canal sin sesión significa reimplementar entero un protocolo de transporte fiable en la capa de aplicación. En cambio, un tipo delta sobre un almacén de objetos se escribe en una tarde: cada réplica sube sus fragmentos, cada réplica lee los de los demás y los mezcla, y no hay más protocolo que ese.

Los cuatro fallos y qué hace cada familia con ellos

Conviene tener el cuadro memorizado porque es el que se usa en las revisiones de diseño. Ante pérdida, un sistema de estado o delta se cura solo: el emisor sigue teniendo la información y el siguiente envío la incluye, sin código de recuperación. Un sistema de operaciones no se cura: la operación perdida hay que retransmitirla, lo que exige conservarla y saber que falta, y si se perdió definitivamente, todas las que dependen de ella quedan retenidas para siempre.

// Un canal adversario de veinte lineas vale mas que cualquier diagrama
function canalHostil({ perdida = 0.1, duplicado = 0.1, retraso = 500 }) {
  return (mensaje, entregar) => {
    if (Math.random() < perdida) return;              // se pierde y nadie avisa
    const veces = Math.random() < duplicado ? 2 : 1;  // duplicado silencioso
    for (let i = 0; i < veces; i++) {
      setTimeout(() => entregar(mensaje), Math.random() * retraso); // reordena
    }
  };
}

Ante duplicación, estado y delta no hacen nada porque la idempotencia lo absorbe. Operaciones necesita recordar identificadores ya aplicados, y esa memoria crece con la actividad y hay que podarla con cuidado, porque podar demasiado pronto reabre la puerta al duplicado tardío.

Ante reordenación, estado y delta son indiferentes entre pares distintos y solo exigen que el tramo de un mismo emisor no tenga huecos. Operaciones exige, para todo par de operaciones que no conmute, un orden compatible con la causalidad, lo que implica búfer, resúmenes causales viajando con cada mensaje y una condición de entrega que hay que evaluar en cada recepción.

Ante reconexión tardía, que es el caso que más incidentes produce, estado y delta tienen una respuesta uniforme: enviar el estado completo, que siempre es válido. Operaciones necesita decidir si retransmite un tramo del registro, si compacta, o si abandona el registro y transfiere estado, que es admitir que hace falta la otra familia para el camino de reparación.

Conviene añadir un quinto escenario que no es un fallo del canal y que produce tantos incidentes como los cuatro anteriores juntos: la restauración desde una copia antigua. Un usuario reinstala la aplicación desde una copia de seguridad de hace tres meses, o un administrador restaura un volumen. En la familia de estado y en la delta eso es indistinguible de una réplica que llevaba tres meses sin conectarse, y la mezcla lo resuelve sin código nuevo. En la familia de operaciones es un caso genuinamente distinto, porque la réplica restaurada tiene una cola de salida y una tabla de vistos que ya no se corresponden con lo que el resto del sistema cree que ella sabe, y la reconciliación de esas dos tablas es un procedimiento que hay que escribir, probar y mantener.

flowchart TD
F[fallo del canal] --> P[perdida]
F --> D[duplicado]
F --> R[reordenacion]
F --> T[reconexion tardia]
P --> E1[estado y delta se curan solos]
D --> E2[la idempotencia lo absorbe]
R --> E3[basta tramo sin huecos por emisor]
T --> E4[enviar el estado completo]
P --> O1[operaciones exigen retransmision]
D --> O2[operaciones exigen deduplicar]
R --> O3[operaciones exigen bufer causal]
T --> O4[operaciones acaban transfiriendo estado]
style E1 fill:#a6e3a1,color:#11111b
style E2 fill:#a6e3a1,color:#11111b
style E3 fill:#a6e3a1,color:#11111b
style E4 fill:#a6e3a1,color:#11111b
style O1 fill:#f38ba8,color:#11111b
style O2 fill:#f38ba8,color:#11111b
style O3 fill:#f38ba8,color:#11111b
style O4 fill:#f38ba8,color:#11111b
⚠️
El diagrama no dice que las operaciones sean peores

Sería una lectura equivocada y bastante cara. Lo que dice el cuadro es que la familia de operaciones traslada trabajo a la capa de transporte, y ese traslado es una mala idea cuando el transporte es débil y una excelente idea cuando ya tienes un transporte fuerte y bien operado. Si tu sistema ya corre sobre un registro replicado ordenado y duradero —porque lo necesitabas para otra cosa— entonces las tres primeras columnas están resueltas por infraestructura que ya pagas, y la familia de operaciones te regala mensajes pequeños y un diario auditable sin coste adicional. El error no es elegir operaciones: es elegirlas sin tener el transporte que presuponen.

Qué han construido de verdad las librerías

Lo más instructivo del panorama actual es que casi ninguna implementación seria es una familia pura, y las desviaciones se parecen mucho entre sí. Yjs modela internamente el documento como una estructura de operaciones con identidad por cliente y contador, pero lo que viaja por el cable no es una operación suelta: es un bloque binario producido por encodeStateAsUpdate a partir del vector de estado del otro lado, es decir, exactamente el conjunto de lo que le falta. El protocolo de sincronización tiene dos pasos —te mando mi vector de estado, me mandas lo que me falta— y el resultado es idempotente y mezclable, hasta el punto de que dos actualizaciones se pueden combinar entre sí antes de aplicarlas. Es un motor de operaciones con un cable que se comporta como un delta.

Automerge toma el camino explícito del registro: cada cambio es un bloque inmutable identificado por su hash y con referencias a los hashes de sus predecesores, con lo que el historial es un grafo dirigido acíclico igual que el de un sistema de control de versiones. La identificación por contenido convierte la aplicación en idempotente sin tabla de vistos, y los cambios cuyas dependencias aún no han llegado se retienen hasta que llegan. Su protocolo de sincronización negocia con resúmenes probabilísticos qué le falta a cada lado en vez de enumerarlo, y la versión 3.0 publicada en 2025 redujo el consumo de memoria alrededor de un orden de magnitud reescribiendo esa representación. También ofrece la vía de escape de siempre: serializar el documento entero cuando el intercambio incremental no compensa.

📝

Yjs

Modelo de operaciones por dentro, intercambio por vector de estado por fuera. El cable se comporta como delta.

🧬

Automerge

Registro de cambios direccionados por hash, dependencias explícitas y sincronización negociada por resumen.

🏛️

Almacenes distribuidos

La familia de Dynamo y sus herederos eligieron estado con vectores de versiones, y después añadieron deltas.

🧵

Kits de datos replicados

Los conjuntos de tipos para actores implementan delta explícitamente, con difusión por rumores y estado completo como reparación.

La coincidencia entre ambas es más profunda que la superficie. Las dos identifican cada unidad de cambio de forma estable —por par de cliente y contador en un caso, por hash del contenido en el otro—, y esa identificación estable es lo que convierte la aplicación repetida en inofensiva sin necesidad de una tabla de vistos separada. Las dos retienen lo que llega antes de tiempo en lugar de rechazarlo. Y las dos ofrecen una vía de serialización completa del documento que se usa como reparación y como formato de almacenamiento. Son, en la terminología de este nivel, dos motores de operaciones con un cable de deltas y una salida de emergencia hacia el estado.

// El patron comun, expresado con la interfaz de una libreria tipica
const miVector    = obtenerVectorDeEstado(documentoLocal);
const loQueFalta  = calcularDiferencia(documentoRemoto, miVector); // delta
aplicarActualizacion(documentoLocal, loQueFalta);                  // idempotente

// Y la salida de emergencia, valida siempre
const completo = serializarDocumento(documentoRemoto);
aplicarActualizacion(documentoLocal, completo);

Del lado servidor el patrón es igual de revelador. Los almacenes derivados de Dynamo adoptaron desde el principio la familia basada en estado con vectores de versiones, porque su modelo de comunicación era anti-entropía entre nodos y no había ningún canal ordenado que aprovechar; cuando el tamaño de los objetos empezó a doler, incorporaron deltas en lugar de cambiar de familia. Los sistemas de replicación activa que corren sobre un enlace propio y fiable entre centros de datos, en cambio, sí replican efectos de operaciones, porque el enlace ya les daba las garantías. Y las bibliotecas de datos replicados para sistemas de actores implementan delta como opción de primera clase con difusión por rumores, exactamente el diseño de la cuarta lección.

La conclusión que emerge del recorrido es que la industria convergió a un mismo punto desde los dos extremos. Quien empezó con operaciones acabó añadiendo un mecanismo para pedir y enviar solo lo que falta y un camino de transferencia completa. Quien empezó con estado acabó añadiendo deltas para no mandar el objeto entero. El resultado, en los dos casos, es un sistema que envía fragmentos pequeños durante la operación normal y objetos completos cuando la contabilidad se pierde.

Un criterio de elección que se pueda defender

Con todo lo anterior, la decisión se puede tomar respondiendo cuatro preguntas por escrito, y el orden importa porque cada una descarta opciones antes de que la siguiente se complique. La primera es qué transporte vas a tener de verdad, incluyendo el peor de los que vayas a soportar; si alguno carece de sesión, la respuesta ya está condicionada. La segunda es cuánto pesa tu objeto típico, porque si cabe holgadamente en un mensaje, el estado puro sigue siendo la opción más simple y no hay que justificar nada más.

La tercera es cuánta conmutatividad hay en tu modelo, medida como fracción de pares de operaciones que se pueden aplicar en cualquier orden; si es alta, no vas a necesitar orden causal y el coste de las operaciones cae mucho. Y la cuarta, que suele decidir cuando las tres anteriores empatan, es quién va a operar esto dentro de dos años y qué incidente sabrá diagnosticar: un búfer causal atascado es un diagnóstico que exige entender el modelo, y una réplica que va retrasada por anti-entropía es un diagnóstico que se resuelve mirando un contador.

ℹ️
La opción por defecto razonable para una aplicación de navegador

Si tienes que decidir hoy y sin más datos, la combinación que menos incidentes produce es un tipo delta sobre un almacenamiento local durable, con difusión entre pestañas para lo inmediato, un canal de servidor para el resto y transferencia completa como camino de reconexión. Esa configuración tolera reinstalaciones, restauraciones desde copias antiguas, clientes que reaparecen tras meses y duplicados de cualquier origen, sin ninguna rama de código dedicada a cada uno de esos casos. Adoptar la familia de operaciones tiene sentido cuando ya vas a operar un registro replicado ordenado por otras razones, o cuando la auditabilidad del diario es un requisito del producto y no una comodidad de depuración.

Una advertencia final que ahorra reescrituras. Sea cual sea la familia elegida, el camino de transferencia completa hay que construirlo desde el primer día y ejecutarlo en cada despliegue, porque es el único mecanismo que repara cualquier estado incoherente y porque es el que nunca se prueba. En los sistemas que fallan de forma memorable, la ruta de reparación existía en el código y no funcionaba, y no funcionaba porque llevaba dieciocho meses sin ejecutarse en ningún entorno real.

No elijas una familia: elige dónde quieres que viva el estado del protocolo, porque en algún sitio va a vivir

Si esta lección tuviera que reducirse a una sola idea con la que tomar decisiones, sería esta: la información que hace posible sincronizar —quién ha visto qué, qué falta por entregar, en qué orden hay que aplicar— no se puede eliminar, solo se puede colocar, y toda la discusión entre familias es una discusión sobre dónde colocarla. En la familia basada en estado, esa información vive dentro del propio dato: el estado carga con su historia comprimida en forma de contadores por réplica o vectores de versiones, y por eso el canal puede ser un directorio compartido. En la familia basada en operaciones vive fuera del dato, en la capa de transporte, en forma de acuses, búferes de reordenación y tablas de identificadores vistos, y por eso el dato puede ser diminuto. En los tipos delta vive repartida a propósito: el fragmento lleva lo justo para colocarse y el transporte lleva lo justo para no dejar huecos. No hay una cuarta opción en la que esa información no exista, del mismo modo que no hay una compresión que elimine entropía. Fíjate en la implicación práctica, que es la que de verdad cambia decisiones. La pregunta correcta al empezar un proyecto no es cuál de las dos familias es mejor —no lo es ninguna— sino cuál de los dos sitios puedes permitirte pagar y operar bien durante años. Si tu equipo va a operar un registro replicado ordenado y duradero de todos modos, poner ahí el estado del protocolo es barato y las operaciones te salen prácticamente gratis, con el diario auditable de regalo. Si tu transporte va a ser una sucesión de conexiones inestables entre dispositivos de usuario, con reinstalaciones, restauraciones desde copias antiguas y clientes que reaparecen tras meses, entonces cada garantía que le pidas al canal es una garantía que vas a tener que defender en cada incidente, y meter el estado del protocolo dentro del dato es lo único que escala. Que las librerías maduras hayan acabado todas en el mismo punto intermedio —fragmentos pequeños con contexto suficiente, y transferencia completa como reparación universal— no es una coincidencia ni una moda: es lo que ocurre cuando un diseño se somete durante años a redes reales, y es la mejor evidencia disponible de que el reparto equilibrado de esa información es el que sobrevive.

⚔️ Decide con tu infraestructura delante
  1. Escribe la lista de transportes que tu aplicación va a usar de verdad y, para cada uno, anota si garantiza entrega, orden y unicidad, y qué ocurre durante una reconexión.
  2. Aplica la regla del transporte sin sesión a tu caso y comprueba si descarta alguna familia antes de seguir discutiendo.
  3. Implementa el banco de pruebas de los cuatro fallos y ejecútalo contra tu prototipo, midiendo tiempo hasta la convergencia en cada escenario.
  4. Simula un cliente que reaparece tras un mes y verifica que tu camino de reparación existe, está probado y no es una rama de código que nadie ejecuta nunca.
  5. Abre el protocolo de sincronización de la librería que estés usando y localiza el punto exacto donde negocia lo que falta y el punto donde cae a transferencia completa.
  6. Escribe en un párrafo dónde vive el estado del protocolo en tu diseño y quién es responsable de operarlo.