Local-first software: apps offline y sincronización con CRDT
1. Descripción general
La mayoría de aplicaciones actuales consideran el servidor como el lugar donde se mantiene la “verdadera” copia de datos, mientras que el cliente solo la muestra. Sin conexión de red, la app casi no es usable. Local-first software invierte este enfoque: los datos viven primero en el dispositivo del usuario, el servidor solo ayuda con la sincronización.
Este artículo explica qué es local-first, por qué es importante, y el rol de CRDT (Conflict-free Replicated Data Type) en fusionar datos sin necesidad de candados o “un único ganador”. Después de leerlo, podrás implementar varios CRDT básicos con Java 21, construir un endpoint de sincronización con Spring Boot 3, y saber cuándo no deberías elegir este enfoque.
2. Qué es local-first y por qué es relevante
Una aplicación local-first típicamente tiene las siguientes características:
- Lectura/escritura instantánea en el dispositivo: toda operación de escritura va al almacenamiento local (SQLite, IndexedDB, archivo…), sin esperar round-trip de red.
- Funciona sin conexión: sin red puedes seguir editando datos. Cuando hay conexión se sincroniza.
- Colaboración en múltiples dispositivos y usuarios: varias copias (replicas) se editan simultáneamente y convergen automáticamente al mismo estado.
- El usuario posee los datos: si el servidor desaparece, los datos siguen estando en el dispositivo.
Hay varias razones por las que este enfoque es más discutido ahora:
- Expectativas de experiencia: los usuarios están acostumbrados a apps que responden al instante, sin spinners después de cada clic.
- Dispositivos más potentes: teléfonos y laptops son capaces de ejecutar bases de datos locales y lógica de fusión de datos.
- Ecosistema más maduro: librerías CRDT como Automerge, Yjs, junto con múltiples engines de sincronización para SQLite, han facilitado la adopción práctica en comparación con escribir desde cero.
- Privacidad y soberanía de datos: mantener datos en el dispositivo del usuario reduce la dependencia de un servidor central.
El mayor desafío es la sincronización. Cuando dos dispositivos editan offline y luego se encuentran, ¿cuál tiene razón?
3. Cómo CRDT resuelve conflictos
CRDT es una estructura de datos diseñada para que cada replica, después de recibir todas las actualizaciones de las otras, siempre converja al mismo estado, sin importar el orden en que se reciban. No necesitas un servidor como árbitro ni candados.
Hay dos familias principales:
- State-based (CvRDT): las replicas se envían todo el estado mutuamente y llaman a
merge. - Operation-based (CmRDT): las replicas envían cada operación. El canal de transmisión debe garantizar que cada operación se entrega exactamente una vez.
Este artículo usa state-based porque es más fácil de entender y demostrar. Para converger, la función merge debe satisfacer tres propiedades:
Conmutativa : merge(a, b) == merge(b, a)
Asociativa : merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotente : merge(a, a) == a
Gracias a estas tres propiedades, que los mensajes lleguen tarde, desordenados o duplicados no afecta el resultado final.
4. Implementación de varios CRDT básicos
Los ejemplos a continuación usan solo JDK. La parte de Spring Boot en la sección 5 requerirá una dependencia adicional.
4.1. G-Counter: contador de solo incremento
Supongamos que deseas contar las visualizaciones de una Order en múltiples dispositivos. Cada replica solo incrementa su propia celda. Cuando haces merge, tomas el valor máximo de cada celda.
import java.util.HashMap;
import java.util.Map;
public final class GCounter {
private final Map<String, Long> counts = new HashMap<>();
public void increment(String replicaId) {
counts.merge(replicaId, 1L, Long::sum);
}
public long value() {
return counts.values().stream().mapToLong(Long::longValue).sum();
}
public void merge(GCounter other) {
other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
}
}
increment solo toca la celda de replicaId, así que dos replicas nunca se sobrescriben. merge usa Math::max, y la operación max es conmutativa, asociativa e idempotente. Prueba rápida:
GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");
phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // repetir merge no afecta el resultado
System.out.println(phone.value() + " " + laptop.value());
3 3
Para soportar también disminución, usa PN-Counter: dos G-Counter, uno para incremento y otro para decremento, el valor es la diferencia de los dos.
4.2. LWW-Register: “última escritura gana”
Para un campo simple como Customer.displayName, la forma más sencilla es mantener el valor con el timestamp más reciente. Si el timestamp es igual, rompe empates usando replicaId para que el resultado sea determinista.
public record LwwRegister<T>(T value, long timestamp, String replicaId) {
public LwwRegister<T> merge(LwwRegister<T> other) {
if (other.timestamp != timestamp) {
return other.timestamp > timestamp ? other : this;
}
return other.replicaId.compareTo(replicaId) > 0 ? other : this;
}
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyễn", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyễn | Lan Nguyễn
Nota: Los relojes en diferentes dispositivos suelen estar desincronizados. Una máquina que corre unos minutos más rápido “ganará” en cada escritura. En la práctica, la gente usa Hybrid Logical Clock o Lamport clock en lugar de tiempo físico puro.
LWW es simple pero silenciosamente perderá una de dos ediciones. Para texto colaborativo, este enfoque no es aceptable. En ese caso necesitas un CRDT para strings, por ejemplo RGA o los algoritmos que usan Automerge y Yjs.
4.3. OR-Set: conjunto con agregar/eliminar
Por ejemplo, tienes una lista de compras compartida. Una implementación ingenua se rompería cuando un dispositivo elimina “Leche” mientras el otro está agregándola de nuevo. Observed-Remove Set lo resuelve etiquetando cada add con un ID único. El comando remove solo elimina tags que esa replica ha visto.
import java.util.*;
public final class ORSet {
private final Map<String, Set<String>> adds = new HashMap<>();
private final Map<String, Set<String>> removes = new HashMap<>();
public void add(String item) {
adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
}
public void remove(String item) {
removes.computeIfAbsent(item, k -> new HashSet<>())
.addAll(adds.getOrDefault(item, Set.of()));
}
public Set<String> elements() {
Set<String> result = new TreeSet<>();
adds.forEach((item, tags) -> {
Set<String> alive = new HashSet<>(tags);
alive.removeAll(removes.getOrDefault(item, Set.of()));
if (!alive.isEmpty()) result.add(item);
});
return result;
}
public void merge(ORSet other) {
union(adds, other.adds);
union(removes, other.removes);
}
private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
}
public ORSetState toState() {
return new ORSetState(adds, removes);
}
public static ORSet fromState(ORSetState state) {
ORSet set = new ORSet();
union(set.adds, state.adds());
union(set.removes, state.removes());
return set;
}
}
import java.util.Map;
import java.util.Set;
public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}
Análisis del funcionamiento:
addgenera un UUID nuevo para cada adición, incluso si agregas un elemento que ya existe.removecopia los tags actuales del elemento aremoves. Los tags agregados por otras replicas que esta replica no ha visto siguen “vivos”.elementsdevuelve elementos que tienen al menos un tag no eliminado. Resultado: cuando se agregan y eliminan simultáneamente, el agregado gana (add-wins).mergees solo una unión de conjuntos, así que satisface las tres propiedades de la sección 3.ORSetStatees un record simple para que Jackson lo serialice a JSON durante la sincronización.
5. Sincronización a través del servidor con Spring Boot
En el modelo local-first, el servidor no decide quién tiene razón. Es solo una replica siempre online que ayuda a los dispositivos a intercambiar estado. Agrega la dependencia web (versión gestionada por spring-boot-starter-parent):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
El controller recibe estado del cliente, hace merge en la versión del servidor, y devuelve el estado fusionado:
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {
private final Map<String, ORSet> store = new ConcurrentHashMap<>();
@PostMapping("/sync")
public synchronized ORSetState sync(@PathVariable String listId,
@RequestBody ORSetState clientState) {
ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
serverCopy.merge(ORSet.fromState(clientState));
return serverCopy.toState();
}
}
El lado del cliente hace la operación inversa: envía estado arriba, recibe estado de vuelta, y llama a merge en su versión local. El cliente puede enviar y reenviar muchas veces porque merge es idempotente, así que reintentar cuando la red es intermitente es seguro. Prueba con curl:
curl -s -X POST localhost:8080/lists/family/sync \
-H "Content-Type: application/json" \
-d '{"adds":{"Leche":["t1"],"Huevo":["t2"]},"removes":{"Huevo":["t2"]}}'
{"adds":{"Leche":["t1"],"Huevo":["t2"]},"removes":{"Huevo":["t2"]}}
El cliente calcula elements() desde el estado devuelto y obtiene [Leche].
Nota:
synchronizedyConcurrentHashMapson solo para demostración. En producción, debes guardar el estado en una base de datos y enviar solo los cambios (delta) en lugar de todo el estado. También necesitas autenticación y autorización para cadalistId.
6. Pruebas del comportamiento convergente
Lo más importante para probar es: dos replicas se editan simultáneamente, hacen merge en cualquier orden, y el resultado debe ser idéntico.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ORSetTest {
@Test
void concurrentAddAndRemove_convergeWithAddWins() {
ORSet phone = new ORSet();
phone.add("Leche");
ORSet laptop = ORSet.fromState(phone.toState()); // sincronización inicial
phone.remove("Leche"); // offline: el teléfono elimina
laptop.add("Leche"); // offline: el laptop agrega de nuevo
laptop.add("Pan");
ORSet a = ORSet.fromState(phone.toState());
a.merge(laptop);
ORSet b = ORSet.fromState(laptop.toState());
b.merge(phone);
assertThat(a.elements()).isEqualTo(b.elements())
.containsExactly("Pan", "Leche");
}
@Test
void merge_isIdempotent() {
ORSet list = new ORSet();
list.add("Huevo");
list.merge(ORSet.fromState(list.toState()));
assertThat(list.elements()).containsExactly("Huevo");
}
}
La primera prueba verifica conmutatividad y la regla add-wins. “Leche” sigue presente porque el laptop lo agregó con un tag que el teléfono nunca vio. La segunda prueba verifica idempotencia. Deberías agregar pruebas basadas en propiedades, es decir, generar secuencias de operaciones aleatorias y órdenes de merge para detectar errores de convergencia sutiles.
7. Errores comunes y cuándo no usar esto
Errores comunes:
- Usar reloj del sistema para LWW sin manejar desincronización. El resultado es que datos “nuevos” se sobrescriben con datos “viejos”.
- Metadatos hinchados: OR-Set mantiene tags de elementos eliminados (tombstones) para siempre. Necesitas un mecanismo de recolección de basura, lo que típicamente requiere saber con certeza que todas las replicas han recibido la operación de eliminación.
- Asumir convergencia significa corrección de negocio: CRDT solo garantiza que todas las replicas sean idénticas, no que el resultado sea lo que el usuario esperaba. Si dos personas editan el título simultáneamente, LWW aún perderá una de las ediciones.
- Sin migración de schema: los datos viven mucho tiempo en el dispositivo, así que versiones viejas y nuevas de la app deben poder leer el estado del otro.
Cuándo no debes usar local-first y CRDT:
- Restricciones globales obligatorias: el saldo no puede ser negativo, el inventario no puede sobreevenderse, cada asiento solo puede ser reservado por una persona. Estas invariantes requieren coordinación centralizada (transacciones, candados), CRDT no puede garantizarlas.
- Datos que requieren control estricto: regulaciones no permiten guardar datos en dispositivos, o necesitas revocar acceso instantáneamente.
- Aplicación CRUD simple, siempre online: la complejidad supera los beneficios. Una API REST con optimistic locking es suficiente.
- Datos muy grandes o compartidos por demasiadas personas: sincronizar todo el estado a cada dispositivo no es viable.
El enfoque práctico típicamente es híbrido: partes colaborativas como notas, listas, borradores usan local-first. Partes transaccionales como pagos a través de PaymentService van por el servidor con restricciones estrictas.
8. Conclusión
Local-first coloca datos en el dispositivo del usuario primero, permitiendo que la app responda al instante y funcione sin conexión. CRDT es la base técnica que permite múltiples replicas editarse simultáneamente y converger automáticamente, gracias a que la función merge es conmutativa, asociativa e idempotente.
En este artículo, implementaste G-Counter, LWW-Register y OR-Set con Java 21, construiste un endpoint de sincronización con Spring Boot, y escribiste pruebas verificando convergencia. Con productos reales, deberías considerar usar librerías CRDT probadas en lugar de escribir las tuyas. Aplica local-first solo a datos que realmente se benefician de ello, y mantén operaciones que requieren restricciones globales en el servidor.