Qué es un conflicto: concurrencia no es simultaneidad
Un conflicto no es que dos escrituras ocurran casi a la vez, sino que ninguna vio a la otra: concurrencia es una relación causal y no una coincidencia del reloj, y esa distinción gobierna todo lo que viene después.
Todo el mundo cree saber qué es un conflicto hasta que tiene que escribir la función que lo detecta. Entonces aparece la pregunta incómoda: ¿qué significa exactamente a la vez? ¿A la vez según qué reloj, medido con qué precisión, en qué dispositivo? La respuesta correcta —y es la que los tres niveles anteriores han estado preparando— es que la simultaneidad física no interviene en absoluto. Dos escrituras separadas por tres semanas pueden ser concurrentes, y dos separadas por cincuenta milisegundos pueden no serlo. Concurrencia no es una propiedad del tiempo: es una propiedad de la información, y significa que ninguna de las dos escrituras vio a la otra. Esta lección instala esa definición con precisión suficiente para programarla, y muestra por qué ni siquiera ella basta por sí sola para hablar de conflicto.
- Sustituir la noción intuitiva de simultaneidad por la relación causal de precedencia y su negación, la concurrencia.
- Reconocer que la concurrencia es necesaria pero no suficiente: un conflicto exige además solapamiento y ausencia de conmutatividad.
- Entender cómo la granularidad elegida para el dato desplaza la frontera de lo que cuenta como el mismo dato.
- Ver por qué una marca de tiempo física no puede establecer causalidad, y qué estructura sí puede.
La definición que casi todo el mundo tiene mal
La formulación de sobremesa —dos usuarios editaron lo mismo al mismo tiempo— contiene tres imprecisiones y cada una de ellas produce después una clase distinta de fallo. La primera es al mismo tiempo, que presupone un reloj global que no existe. La segunda es lo mismo, que presupone que el dato tiene una frontera evidente, cuando en realidad la eliges tú al diseñar el esquema. Y la tercera, la más sutil, es editaron, que presupone que dos escrituras sobre un mismo dato son necesariamente incompatibles, cosa que es falsa en una cantidad enorme de casos interesantes.
La corrección de la primera imprecisión viene directamente del orden de precedencia causal que el nivel anterior formalizó. Un evento a precede a otro b cuando existe una cadena de causas que va del primero al segundo: ocurrieron en el mismo nodo en ese orden, o b es la recepción de un mensaje que a envió, o hay una composición de ambas cosas. Cuando no existe tal cadena en ninguna de las dos direcciones, se dice que a y b son concurrentes, y se escribe a || b. La relación de precedencia es un orden parcial, y la concurrencia es exactamente el conjunto de pares que ese orden deja sin comparar.
Fíjate en lo que esta definición elimina y en lo que conserva. Elimina el reloj: no aparece ningún instante, ninguna duración, ninguna tolerancia. Y conserva lo único que de verdad importaba, que es qué sabía cada escritor en el momento de escribir. Un usuario que sobrescribe un valor habiendo visto el valor anterior está tomando una decisión informada, y no hay nada que resolver: quiso reemplazarlo. Un usuario que sobrescribe un valor sin haber visto lo que el otro escribió no está decidiendo nada, porque ignoraba la existencia de la alternativa. Esa es toda la diferencia, y es una diferencia epistémica antes que temporal.
flowchart LR A[escritura en el portatil sin red] --> S1[sincroniza tres semanas despues] B[escritura en el movil ayer] --> S2[sincroniza ayer mismo] S1 --> C[ninguna vio a la otra] S2 --> C C --> R[son concurrentes pese a las tres semanas] style A fill:#f9e2af,color:#11111b style B fill:#f9e2af,color:#11111b style R fill:#f38ba8,color:#11111b
De ahí se sigue el corolario que da título a la lección y que conviene enunciar sin matices: la distancia temporal entre dos escrituras no aporta ninguna información sobre si están en conflicto. Puede correlacionar débilmente con la concurrencia en sistemas muy conectados, y esa correlación débil es precisamente la trampa, porque funciona lo bastante bien en el escritorio del desarrollador como para que nadie descubra que el criterio era falso hasta que un usuario pasa un fin de semana sin cobertura.
El antipatrón que nace de esa correlación aparece en tantas bases de código que merece nombre propio: la ventana de conflicto, una constante en segundos dentro de la cual dos escrituras se consideran simultáneas y fuera de la cual se aplican sin más. Es un parámetro que no se puede ajustar bien porque no existe ningún valor correcto: si lo agrandas, marcas como conflictivas escrituras perfectamente ordenadas; si lo achicas, dejas pasar sin mirar todo lo que venga de un dispositivo que estuvo desconectado. La ventana no aproxima la concurrencia, la sustituye por una magnitud que no guarda relación con ella.
// El antipatron de la ventana: ninguna eleccion de VENTANA_MS es correcta
const VENTANA_MS = 5000;
function pareceConflicto(a, b) {
return Math.abs(a.ts - b.ts) < VENTANA_MS; // no mide causalidad, mide cercania
}
pareceConflicto({ ts: 0 }, { ts: 1_814_400_000 }); // false: tres semanas offline
pareceConflicto({ ts: 0 }, { ts: 40 }); // true: el segundo leyo al primero
Los dos casos del final son los dos errores, uno de cada signo, y ninguno es rebuscado: el primero es un portátil que vuelve de vacaciones y el segundo son dos guardados automáticos consecutivos de la misma persona. Un criterio que se equivoca en ambas direcciones sobre los escenarios más corrientes de una aplicación local-first no es una aproximación imperfecta que convenga refinar: es una magnitud distinta de la que se pretendía medir.
Concurrencia sola no basta: hacen falta tres condiciones
Una vez limpia la primera imprecisión, aparece la tercera, que es la más interesante. Supón dos escrituras genuinamente concurrentes sobre el mismo contador: una lo incrementa en uno y otra lo incrementa en tres. Ninguna vio a la otra, ambas tocan el mismo dato y sin embargo no hay absolutamente nada que decidir, porque el resultado correcto —incrementar en cuatro— está determinado sin ambigüedad y no depende del orden en que se apliquen. No hay conflicto. Lo mismo ocurre al añadir dos elementos distintos a un conjunto, al marcar dos casillas diferentes o al anexar dos líneas a un registro append-only.
Lo que distingue esos casos del caso patológico no es la conducta de los usuarios sino el álgebra de la operación. Dos operaciones conflictúan cuando su composición no es conmutativa, es decir, cuando aplicarlas en un orden produce un estado distinto que aplicarlas en el otro y no existe ninguna razón principiada para preferir uno de los dos órdenes. Asignar un valor absoluto —titulo = "Informe" frente a titulo = "Borrador"— es la operación no conmutativa por excelencia, y no por casualidad es la operación que casi todas las API de datos ofrecen por defecto.
Concurrencia causal
Ninguna de las dos escrituras figura en el pasado causal de la otra. Sin esta condición no hay nada que discutir: hubo una decisión informada.
Solapamiento del estado
Ambas afectan a la misma región del estado. Escribir en campos disjuntos es concurrente y perfectamente inocuo.
Ausencia de conmutatividad
El resultado depende del orden de aplicación y ningún orden es defendible sobre el otro. Aquí es donde nace el conflicto de verdad.
El contraejemplo que lo aclara
Dos incrementos concurrentes del mismo contador cumplen las dos primeras condiciones y no la tercera. Y por eso no son un conflicto.
Esta reformulación tiene una consecuencia estratégica que el resto del track desarrollará durante quince niveles: si el conflicto es un fenómeno algebraico, entonces se puede eliminar por diseño eligiendo operaciones que conmuten, en lugar de detectarlo y resolverlo caso por caso. Modelar una casilla como un contador de activaciones y desactivaciones en vez de como un booleano asignable, o una lista como un conjunto de inserciones identificadas en vez de como un array reasignable, cambia la naturaleza del problema antes de que aparezca. Es la diferencia entre construir un tribunal y escribir leyes que no puedan contradecirse.
Cuando audites una aplicación buscando conflictos potenciales, no mires los datos: mira las operaciones que tu código emite sobre ellos. Un mismo campo puede ser inofensivo si solo se le anexa y catastrófico si se le reasigna. La pregunta operativa es siempre la misma y se responde en un minuto por cada operación: si dos réplicas la ejecutan sin verse y luego intercambian, ¿el estado final depende de quién habló primero? Si la respuesta es no, ese punto está cerrado para siempre. Si es sí, acabas de encontrar un conflicto real, y ya sabes cuál es antes de que ningún usuario lo sufra.
La granularidad decide qué es el mismo dato
Queda la segunda imprecisión, y es una decisión de diseño disfrazada de hecho objetivo. Que dos escrituras toquen el mismo dato depende íntegramente de cómo hayas decidido trocear el estado. Si tu unidad es el documento entero, cualquier par de ediciones concurrentes sobre él solapa. Si tu unidad es el campo, dos ediciones en campos distintos dejan de solapar y el conflicto desaparece. Si tu unidad es el carácter dentro de un texto, dos personas escribiendo en párrafos diferentes ni se rozan.
La tentación evidente es afinar la granularidad hasta que el problema se evapore, y funciona en parte: la tasa de conflictos cae de forma pronunciada, porque la probabilidad de que dos ediciones aterricen en la misma unidad decrece con el tamaño de la unidad. Pero hay dos costes que conviene tener presentes antes de celebrar. El primero es de contabilidad: cuanto más fina la unidad, más metadatos de causalidad hay que mantener y transportar, y ese coste no es marginal. El segundo es más grave y no se arregla con recursos.
Al descender de granularidad puedes fabricar estados que ninguna de las dos réplicas habría producido nunca. Si un intervalo tiene fechaInicio y fechaFin con la invariante evidente entre ambas, y una réplica adelanta el inicio mientras otra adelanta el fin, la fusión campo a campo es impecable en cada campo por separado y produce un intervalo imposible. Nadie escribió ese estado; lo escribió el algoritmo de fusión. Es el conflicto semántico, y su rasgo definitorio es que ningún detector sintáctico puede verlo, porque sintácticamente no hay solapamiento alguno.
// Dos escrituras concurrentes, ningun campo compartido, resultado imposible
const base = { inicio: "2026-03-10", fin: "2026-03-20" };
const replicaA = { ...base, fin: "2026-03-12" }; // adelanta el fin
const replicaB = { ...base, inicio: "2026-03-15" }; // adelanta el inicio
const fusionPorCampo = { inicio: replicaB.inicio, fin: replicaA.fin };
// -> { inicio: "2026-03-15", fin: "2026-03-12" }: el fin precede al inicio
La conclusión no es que la granularidad fina sea mala, sino que la granularidad es una palanca que redistribuye el problema en vez de resolverlo: baja la frecuencia de los conflictos sintácticos y sube la de los semánticos, que son más raros y bastante peores. La unidad correcta no es la más pequeña posible, sino la más pequeña que siga siendo cerrada bajo las invariantes de tu dominio. Los campos que se restringen mutuamente pertenecen a la misma unidad, y separarlos es exportar la contradicción al algoritmo de fusión.
Hay un segundo eje de troceado que se pasa por alto y que rinde tanto como el primero. Además de decidir qué porción del estado es una unidad, decides qué porción de la actividad del usuario es una escritura: si cada pulsación de tecla emite una operación, si se agrupan por pausas, si se envía una sola al guardar. Agrupar mucho fabrica solapamientos artificiales, porque una escritura enorme choca con todo lo que se cruce en su camino aunque el cambio real fuera de dos palabras. Agrupar poco multiplica las operaciones y su contabilidad, pero produce solapamientos que se corresponden con lo que la persona hizo de verdad. Ese eje es tan decisorio como el otro y suele quedar fijado sin discusión por el detalle de implementación de cuándo dispara el guardado automático.
Lo que un reloj de pared no puede decirte
Falta cerrar el círculo con la pregunta práctica: si la concurrencia es una relación causal, ¿cómo la calcula un programa? La respuesta empieza por descartar con firmeza la vía que todo el mundo intenta primero, que es comparar marcas de tiempo. Una marca de tiempo física registra cuándo un nodo creyó que escribía, según un reloj que deriva, que se sincroniza a saltos y que puede retroceder. Pero incluso con relojes perfectos el argumento se sostiene: dos instantes distintos son siempre comparables, de modo que un orden por tiempo es total por construcción y, siendo total, no tiene manera de representar el estado incomparable. Comparar marcas de tiempo no es que detecte mal la concurrencia: es que la definición de concurrencia no es expresable en ese lenguaje.
La estructura que sí lo expresa es la que el nivel anterior construyó. Un vector de versiones asocia a cada réplica su contador de eventos conocidos, y al comparar dos vectores aparecen exactamente tres desenlaces posibles en lugar de dos: uno domina componente a componente, lo domina el otro, o ninguno domina al otro. Ese tercer caso, imposible en un orden total, es la concurrencia hecha valor computable.
// Tres desenlaces, no dos: la concurrencia es el tercero
function comparar(v1, v2) {
const claves = new Set([...Object.keys(v1), ...Object.keys(v2)]);
let algunoMayor = false;
let algunoMenor = false;
for (const k of claves) {
const a = v1[k] ?? 0;
const b = v2[k] ?? 0;
if (a > b) algunoMayor = true;
if (a < b) algunoMenor = true;
}
if (algunoMayor && algunoMenor) return "concurrentes"; // ninguno vio al otro
if (algunoMayor) return "v1 sucede despues";
if (algunoMenor) return "v2 sucede despues";
return "identicos";
}
Conviene tener presente el precio de esa capacidad, porque no es gratis y explica por qué tanta gente elige no pagarlo. El vector crece con el número de réplicas que han escrito alguna vez, de modo que su tamaño no está acotado por el diseño sino por el uso: una aplicación con muchos dispositivos por usuario acumula entradas que ya nadie utiliza y que hay que podar con cuidado, porque eliminar la de una réplica que reaparece meses después reintroduce ambigüedad. Existen variantes más económicas —vectores podados, versiones con puntos, resúmenes causales— y todas negocian sobre el mismo eje: cuánta memoria de quién vio qué estás dispuesto a conservar.
La línea que devuelve "concurrentes" es, literalmente, todo el aparato de detección de conflictos de un sistema local-first. Todo lo que las cuatro lecciones siguientes discuten —qué hacer con ese caso, quién decide, qué se destruye al decidir mal— presupone que esa línea existe y se ejecuta. Un sistema que carece de ella no es que resuelva mal los conflictos: es que nunca llega a saber que los tuvo, y por eso no puede informar de nada a nadie.
Merece la pena detenerse en la magnitud de lo que acaba de ocurrir, porque es el punto donde el track deja de ser una colección de técnicas y se convierte en una postura sobre la naturaleza de los sistemas distribuidos. En la arquitectura cliente-servidor que estamos abandonando, los conflictos parecían no existir, y esa apariencia era genuina desde dentro: el servidor recibía las escrituras por un canal, las serializaba en un orden y ese orden era la historia, por definición y sin apelación. No es que el servidor resolviera los conflictos mejor que nosotros; es que su existencia como punto único de serialización los volvía inexpresables, igual que un orden total vuelve inexpresable la incomparabilidad. Cuando retiras ese punto —y retirarlo es exactamente lo que significa local-first, no un detalle de implementación— el orden parcial que siempre estuvo ahí, oculto bajo la serialización, aflora entero y con él la posibilidad de que dos historias sean legítimas a la vez. Fíjate en que esto no es un defecto que la ingeniería vaya a limar en la próxima versión, ni un problema abierto esperando un algoritmo más listo: es un teorema sobre lo que la información puede saber de sí misma. Dos nodos que no se han comunicado no pueden tener opiniones ordenadas entre sí, del mismo modo que dos sucesos separados por más espacio del que la luz recorre en su intervalo no tienen un orden absoluto en relatividad, y no es casualidad que Lamport llegara a su relación de precedencia leyendo precisamente esa analogía. La consecuencia de diseño es tan liberadora como severa. Severa, porque prohíbe toda esperanza de que un sistema bien programado no tenga conflictos: si permites trabajo sin conexión, tendrás órdenes parciales, y si tienes órdenes parciales, tendrás pares incomparables. Liberadora, porque desplaza la pregunta de ingeniería a un terreno donde sí hay respuestas: no cómo evito los conflictos, que es una pregunta mal formada, sino cuáles de mis operaciones conmutan —esas ya están resueltas y no requieren nada más—, cuáles no conmutan y por tanto exigen una política explícita, y de esas últimas cuáles admiten una fusión defendible y cuáles necesitan a un humano. Esa clasificación, hecha operación por operación sobre tu modelo de datos, es el trabajo real de esta parte del track. Todo lo demás son consecuencias.
- Enumera las operaciones que tu aplicación emite sobre el estado y, para cada una, responde por escrito si dos réplicas que la ejecutan sin verse convergen al mismo resultado con independencia del orden.
- Separa la lista en dos columnas —conmutan y no conmutan— y comprueba cuántas de las segundas son asignaciones absolutas que podrías remodelar como operaciones incrementales.
- Busca en tu esquema dos campos ligados por una invariante y escribe el par de escrituras concurrentes que, fusionadas campo a campo, producen un estado imposible.
- Implementa la comparación de vectores de versiones y verifica con casos escritos a mano que devuelve los tres desenlaces, no dos.
- Toma un incidente real de tu historial en el que se perdieron datos y determina si las escrituras implicadas eran concurrentes en sentido causal o si simplemente estaban próximas en el reloj.