Offline-first es una decisión de modelo de datos, no una función de caché
La mayoría de apps añaden el soporte offline al final y descubren que siempre fue una cuestión de arquitectura. Qué tiene que cambiar de verdad, y entre qué reglas de conflicto hay que elegir.
«Que funcione sin conexión» suena a una funcionalidad que cabe en un sprint. No lo es. Cambia dónde viven tus datos y quién decide qué es verdad, y eso toca todo.
La buena noticia es que el trabajo está bien comprendido. La mala es que meterlo a posteriori cuesta varias veces más que construirlo desde el principio.
Dos arquitecturas
La arquitectura por defecto es con autoridad en el servidor. La pantalla refleja el servidor. Una acción envía una petición, espera y actualiza con la respuesta. Sin conexión significa roto, porque no hay nada que mostrar ni dónde escribir.
La alternativa es local-first. El dispositivo tiene su propia base de datos y la interfaz lee de ahí. Las escrituras van al almacenamiento local de inmediato y la pantalla se actualiza desde él. Un proceso en segundo plano reconcilia con el servidor cuando hay conexión.
La inversión lo es todo: la red deja de estar entre el usuario y sus datos. Todo lo demás se deriva de ahí.
Qué cambia de verdad
Cada registro necesita una identidad que se pueda generar sin conexión
Si el servidor asigna los IDs, un dispositivo no puede crear nada sin conexión sin inventar un ID temporal y reescribir después todas las referencias. Genera los IDs en el cliente, con UUID o similar, desde el primer día. Esta decisión es barata al principio y dolorosa de retroajustar.
Las escrituras pasan a ser una cola, no una llamada
Una acción añade una intención a una cola local persistente y vuelve de inmediato. Un proceso de sincronización la vacía. Lo que significa que la cola debe sobrevivir al cierre de la app, reintentar con espera creciente y ser idempotente, porque enviarás algunas operaciones dos veces.
Los borrados dejan de ser borrados
Si un dispositivo borra una fila sin conexión y la sincronización solo envía lo que existe, el servidor nunca se entera y te la devuelve amablemente. Necesitas lápidas: el borrado registrado como un hecho con marca temporal, conservado el tiempo suficiente para que lo vean todos los dispositivos.
La interfaz tiene que mostrar el estado de sincronización
Cuando una escritura puede tener éxito localmente y fallar después, el usuario necesita saberlo. No un spinner en todo, sino un indicador honesto: guardado local, sincronizando, sincronizado, fallido. Ocultarlo produce el peor resultado posible: alguien creyendo que su trabajo está a salvo cuando no lo está.
Elegir una regla de conflicto
Dos dispositivos editan el mismo registro sin conexión. Ambos vuelven. Algo tiene que decidir. No hay respuesta universalmente correcta; hay una respuesta correcta para tus datos.
| Estrategia | Cómo funciona | Buena para |
|---|---|---|
| Gana la última escritura | La marca temporal mayor sobrescribe | Ajustes, estado por dispositivo, campos de bajo riesgo |
| Fusión por campo | Marca temporal por campo; fusiona los que no chocan | Registros donde cada uno edita partes distintas |
| Registro de operaciones | Guarda intenciones y las reproduce en orden | Contadores, listas, todo lo aditivo |
| CRDT | Estructuras que fusionan de forma determinista | Texto colaborativo y documentos compartidos |
| Preguntar al usuario | Muestra ambas y que elija | Solo conflictos raros y de alto valor |
Dos avisos de experiencia. «Gana la última escritura» con relojes de dispositivo pierde datos en silencio, porque esos relojes están mal; usa marcas asignadas por el servidor o relojes lógicos. Y «preguntar al usuario» no es un valor por defecto; es la salida de emergencia para casos demasiado costosos de resolver solos. Si preguntas lo suficiente, la gente acepta sin leer.
Elige la regla de conflicto por tipo de dato, no por aplicación. El progreso de tarjetas de un usuario puede ser aditivo y fusionar limpiamente. Su nombre visible puede ser «gana la última escritura». La misma regla para ambos está mal para uno de los dos.
La sincronización en sí
La forma que funciona para la mayoría de productos:
- Cada registro lleva una versión o marca de actualización asignada por el servidor.
- El cliente guarda un cursor: el punto hasta el que se ha puesto al día.
- Al sincronizar, el cliente envía las escrituras encoladas y su cursor.
- El servidor aplica las escrituras, resuelve conflictos y devuelve todo lo cambiado desde ese cursor más un cursor nuevo.
- El cliente aplica los cambios y avanza el cursor de forma atómica.
La atomicidad del paso 5 importa más de lo que parece. Si avanzas el cursor y luego fallas al aplicar, te has saltado cambios en silencio y nada te lo va a decir.
Prueba los casos feos a propósito
Los fallos offline no aparecen en pruebas normales, porque las pruebas normales tienen wifi. Mete esto en tu plan:
- Modo avión durante una semana, y luego reconectar con cientos de operaciones encoladas.
- Cierre forzado a mitad de sincronización. Mata la app con un lote en vuelo. No puede perderse nada ni aplicarse dos veces.
- Portales cautivos. El peor estado de red: el dispositivo dice que hay conexión y todas las peticiones devuelven una página de login. Deben fallar limpiamente, no corromper estado.
- Desfase de reloj. Pon el reloj del dispositivo un día fuera y confirma que el orden se mantiene.
- Dos dispositivos, misma cuenta, ambos sin conexión, editando los mismos registros. Ahí es donde las reglas de conflicto se ganan el sueldo.
Cuándo no hacerlo
Offline-first es trabajo real y no siempre está justificado. Sáltatelo cuando la app no tiene sentido sin red de todos modos, como chat en vivo o pagos; cuando los datos son intrínsecamente compartidos y simultáneos, donde seguramente quieres tiempo real y no offline; o cuando es una herramienta interna de poco uso donde el coste supera al beneficio.
Hazlo cuando la gente usa la app donde la conectividad es irregular: trayectos, transporte público, aviones, zonas rurales, edificios con mala cobertura. En una app de aprendizaje eso es la mayoría de las sesiones, por eso lo tratamos como supuesto de partida y no como una petición de funcionalidad.
La versión más barata
Si una arquitectura local-first completa es demasiado para tu etapa, hay un término medio amplio que merece la pena:
- Cachea las lecturas con ganas para que la app abra con contenido y no con un spinner.
- Encola las escrituras de forma persistente aunque no haya sincronización completa, para que una acción nunca se pierda por un corte.
- Genera los IDs en el cliente desde ya, porque hoy no cuesta nada y mañana lo desbloquea todo.
Esas tres cosas dan la mayor parte del beneficio percibido por una fracción del trabajo, y ninguna se desperdicia si más adelante vas más lejos.