wandres.dev
ARQUITECTURA RECOMENDADA · capas y flujo unidireccional

Offline-first: la base local como verdad y la red como sincronización

El enfoque offline-first dejó de ser una característica de aplicaciones especiales para convertirse en el comportamiento que se espera por defecto de cualquier cliente serio, y la guía oficial de arquitectura lo recoge como el modo normal de construir la capa de datos. Esta lección desarrolla lo que implica de verdad: invertir la relación entre disco y red hasta que la base local sea la única fuente de verdad y la red un proceso de reconciliación en segundo plano, escribir de forma optimista sin mentirle al usuario, sostener una cola de operaciones pendientes que sobreviva a la muerte del proceso, y resolver los conflictos que aparecen cuando dos verdades divergen, incluida la parte que ninguna estrategia técnica puede decidir por ti.

⏱ 22 min

Durante años la conectividad se trató como una condición normal y su ausencia como una excepción que se señalaba con un icono triste y un botón de reintentar. Ese modelo mental nació en escritorios con cable y sobrevivió por inercia a una década de dispositivos que pasan el día entrando y saliendo de túneles, ascensores, sótanos y aviones. La realidad del móvil nunca fue esa: la conectividad no es un interruptor sino una variable continua y hostil, con latencias que oscilan dos órdenes de magnitud, redes que aceptan la conexión y no entregan nada, y sistemas operativos que suspenden tu proceso a mitad de una petición para ahorrar batería. Una aplicación diseñada para el caso feliz no falla de vez en cuando en ese entorno: falla constantemente y disimula. La inversión que propone el enfoque offline-first es exactamente esa, una inversión: dejar de tratar la ausencia de red como un error y empezar a tratar su presencia como una oportunidad.

🎯 Al terminar esta lección sabrás
  • Reformular la capa de datos para que la base local sea la fuente única de verdad y la red un proceso de reconciliación.
  • Diseñar lecturas que no dependan de la conectividad y escrituras que se confirmen antes de llegar al servidor.
  • Sostener una cola de operaciones pendientes que sobreviva a la muerte del proceso y al reinicio del dispositivo.
  • Elegir una estrategia de resolución de conflictos y comunicar al usuario lo que el sistema sabe y lo que no.

Invertir quién manda

En una arquitectura convencional la pantalla pide, el repositorio llama al servidor, el resultado se guarda por si acaso y se muestra. La caché existe pero es secundaria: se consulta cuando la red falla, es decir, en el peor momento y con la información más pobre. En una arquitectura offline-first el orden se invierte por completo. La pantalla observa la base local y solo la base local. La red no participa en la lectura en absoluto: su papel es escribir en el disco cuando puede, y lo que ocurre a continuación es que la pantalla se entera porque estaba observando.

Dicho de otro modo: la red deja de ser un participante en la conversación entre la pantalla y los datos, y pasa a ser un actor externo que empuja información hacia el almacén cuando puede. La pantalla nunca la espera porque nunca la ha llamado.

Ese cambio de orden tiene una consecuencia inmediata que conviene apreciar antes de seguir. La capa de UI queda completamente desconectada del problema de la conectividad, no por disciplina sino por construcción: no tiene ningún camino por el que enterarse de que existe una red. Todo el trabajo difícil se concentra en un único lugar, el repositorio, y todo el resto del sistema se vuelve más simple, no más complejo.

Merece la pena señalar que este enfoque no añade una capa nueva a la arquitectura: reutiliza exactamente las piezas de la lección anterior y se limita a fijar cuál de las fuentes manda. La fuente local deja de ser una caché opcional y pasa a ser la verdad; la fuente remota deja de ser el origen y pasa a ser un colaborador que propone actualizaciones. Todo el resto del contrato del repositorio queda igual, y por eso adoptar offline-first en una capa de datos bien construida es un cambio interno que no toca ninguna pantalla.

class TareaRepositoryImpl(
    private val dao: TareaDao,
    private val api: TareaApi,
    private val cola: ColaDeSincronizacion,
) : TareaRepository {

    // la lectura jamas toca la red
    override fun observar(): Flow<List<Tarea>> =
        dao.observarTodas().map { filas -> filas.map(TareaEntity::aDominio) }

    override suspend fun sincronizar(): Result<Unit> = runCatching {
        val remotas = api.listar(desde = dao.ultimaMarca())
        dao.insertarOActualizar(remotas.map(TareaDto::aEntidad))
    }
}
flowchart TD
A[Pantalla] -->|observa| B[Base de datos local]
C[Repositorio] -->|escribe| B
D[Sincronizacion en segundo plano] --> C
E[Servidor] --> D
C -->|encola cambios locales| F[Cola de pendientes]
F --> E
style B fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

Hay un matiz que suele perderse en la traducción del esquema al código. La lectura no toca la red, pero eso no significa que la aplicación no pida nada nunca: significa que pedir es una operación independiente de mostrar. Alguien dispara la sincronización, sea el arranque, un gesto de arrastre, un temporizador o una notificación del servidor, y el resultado llega a la pantalla por el mismo camino por el que llegan todos los demás cambios. Separar esos dos flujos es lo que permite que la interfaz no tenga que saber cuál de ellos la despertó.

Hay un detalle del esquema que decide si el enfoque funciona: la sincronización debe ser incremental. Traerse la colección completa en cada arranque desperdicia datos del usuario, castiga la batería y no escala más allá de unos pocos miles de elementos. El servidor debe ofrecer alguna marca de cambio, sea una fecha, un número de versión o un testigo opaco, y el cliente debe guardarla junto a los datos. Cuando esa marca no existe en el servidor, el trabajo de negociarla es parte del proyecto y no un detalle que se pueda esquivar en el cliente.

Escribir sin esperar

Y conviene no idealizar el borrado: eliminar un registro en local mientras la operación viaja al servidor deja un hueco que puede reaparecer si la siguiente sincronización lo vuelve a traer. La forma robusta es marcar el registro como borrado en lugar de quitarlo, ocultarlo en la lectura y retirarlo de verdad cuando el servidor confirme. Es más código y es la diferencia entre un borrado que se queda borrado y uno que resucita.

La mitad de lectura del problema es la fácil. La mitad difícil son las escrituras, porque el usuario espera que su acción tenga efecto inmediato y el servidor todavía no sabe nada de ella. La respuesta correcta es escribir en local de inmediato, marcar el registro como pendiente y devolver el control. La pantalla, que observa la base, se actualiza sin haber esperado a nadie.

override suspend fun completar(id: TareaId) {
    dao.actualizar(id) { fila -> fila.copy(completada = true, pendiente = true) }
    cola.encolar(OperacionPendiente.Completar(id))
}

Ese patrón tiene una consecuencia agradable en la prueba: la escritura y su envío se pueden verificar por separado. Comprobar que marcar una tarea deja el registro actualizado y una operación en la cola no requiere red alguna, y comprobar que la cola envía bien no requiere interfaz. La mayoría de los fallos de sincronización que llegan a producción vienen de sistemas donde ambas cosas están enredadas y solo se pueden probar juntas, es decir, en la práctica nunca.

Esa marca de pendiente no es un detalle de implementación: es información que a menudo el usuario merece ver. La diferencia entre una aplicación que da confianza y una que no suele estar en si el mensaje enviado muestra un reloj hasta confirmarse o si aparece exactamente igual que los demás y desaparece media hora después sin explicación.

Las escrituras optimistas exigen además decidir qué ocurre cuando el servidor rechaza lo que ya se mostró como hecho. Deshacer en silencio es la peor de las opciones, porque el usuario vio su cambio aplicado y ahora ve otra cosa sin explicación. Lo correcto es revertir y decirlo, con un mensaje que identifique qué operación falló y ofrezca reintentarla; y en operaciones con consecuencias reales, como un pago, sencillamente no debe haber optimismo.

Otro detalle que decide la calidad del resultado es la identidad. Un registro creado sin conexión todavía no tiene identificador del servidor, así que necesita uno local que permita referirse a él, editarlo o borrarlo antes de que exista fuera. Cuando la creación se confirme, el repositorio debe reconciliar ambos identificadores sin romper las referencias que otros registros hayan creado mientras tanto. Generar el identificador en el cliente, con un espacio de nombres suficientemente amplio, evita casi todo ese trabajo y es lo que hacen los sistemas que funcionan bien.

Encolar tiene además una exigencia dura: la cola debe sobrevivir a la muerte del proceso. Una lista en memoria pierde el trabajo del usuario en cuanto el sistema recorta memoria, que es precisamente lo que ocurre cuando alguien deja la aplicación en segundo plano después de escribir algo sin cobertura. La cola vive en la misma base de datos que el resto de la verdad, y su ejecución se delega en un planificador que respeta las restricciones del sistema, con reintentos espaciados y persistencia entre arranques.

⚠️
La sincronización no puede vivir en el ámbito de una pantalla

Lanzar la reconciliación desde el ámbito de un ViewModel funciona en el emulador y falla en producción, porque ese ámbito muere cuando el usuario sale de la pantalla y el sistema no garantiza nada más allá. El trabajo diferido pertenece a un planificador que sepa esperar a que haya red, sobrevivir al reinicio del dispositivo, reintentar con espera creciente y agrupar ejecuciones. En Android esa pieza es WorkManager, y su uso aquí no es opcional: es lo que separa una sincronización de una esperanza.

Cuando las dos verdades no coinciden

Conviene además que la cola sea observable, porque su contenido es información que la interfaz a menudo necesita: cuántos cambios están pendientes, si alguno falló definitivamente, cuándo fue la última sincronización correcta. Exponer eso como parte del estado que el repositorio publica evita que cada pantalla invente su propia manera de averiguarlo.

En cuanto se admite escritura local sin confirmación, se admite la posibilidad de divergencia: el mismo registro cambió aquí y allí. No hay forma de eliminar esa posibilidad sin renunciar a la premisa, así que el trabajo consiste en elegir cómo se resuelve, y esa elección es específica de cada tipo de dato.

🕐

Gana el último

Simple y suficiente para preferencias y ajustes. Requiere un reloj fiable, y el del dispositivo no lo es.

🏢

Gana el servidor

Correcto para datos que el servidor calcula o valida. El cliente descarta su cambio y avisa si el usuario lo notaría.

🧩

Fusión por campos

Cada campo se resuelve por separado. Más trabajo, mucho mejor experiencia en formularios largos.

🙋

Decide el usuario

La única honesta cuando el conflicto es semántico. Cara, pero obligatoria en documentos y notas.

La elección no se hace una vez para toda la aplicación sino por tipo de dato, y a veces por campo. En una nota, el cuerpo del texto merece fusión o intervención humana y la fecha de última apertura merece que gane el último. Tratar ambos con la misma política significa que uno de los dos quedará mal resuelto, y suele ser el que le importa al usuario.

Sobre los relojes conviene ser explícito porque es un error frecuente y silencioso: la hora del dispositivo la fija el usuario, se desvía, cambia de zona y a veces salta hacia atrás. Cualquier estrategia que compare marcas temporales generadas en dispositivos distintos está construida sobre arena. Lo robusto es que el orden lo establezca el servidor, con versiones monótonas o contadores por registro, y que el cliente se limite a transportarlos.

Conviene también aceptar que el orden de la cola importa. Si el usuario crea un elemento y lo borra a continuación sin conexión, enviar las dos operaciones al recuperar la red es correcto pero derrochador, y enviarlas desordenadas es directamente un error. Una cola que sabe fusionar operaciones sobre el mismo registro, respetando su orden relativo, elimina una familia entera de fallos que de otro modo aparecen solo en escenarios que nadie prueba.

Hay además una decisión de diseño que reduce la mayoría de los conflictos antes de que existan, y es preferir operaciones a estados. Enviar el hecho de que el usuario marcó la tarea como completada es muy distinto de enviar el objeto tarea entero con todos sus campos: lo primero se puede aplicar sobre cualquier versión del registro sin pisar los cambios ajenos; lo segundo sobreescribe todo lo que otro haya tocado mientras tanto. La misma idea explica por qué los contadores deben sincronizarse como incrementos y no como totales.

El límite honesto de lo local

Y hay un tipo de conflicto que ninguna estrategia técnica resuelve: el semántico. Dos personas editaron el mismo párrafo de una nota compartida y las dos versiones son legítimas. Elegir una automáticamente es destruir trabajo humano sin avisar, y ninguna heurística lo justifica. En ese caso la única respuesta honesta es conservar ambas y pedir a alguien que decida, aunque cueste una pantalla más.

Queda por decir dónde este enfoque no aplica, porque venderlo como universal es la forma más rápida de desacreditarlo. Hay datos cuya validez es intrínsecamente instantánea: la disponibilidad de un asiento, el precio de una acción, el saldo de una cuenta antes de una transferencia. Mostrar una versión guardada de esos valores no es una degradación elegante, es una afirmación falsa con consecuencias.

Conviene distinguir dos formas de mostrar un dato viejo, porque una es aceptable y la otra no. Mostrarlo diciendo cuándo se obtuvo, con la sincronización visiblemente en curso, es información honesta y casi siempre preferible a una pantalla en blanco. Mostrarlo sin ninguna marca, indistinguible de un dato recién llegado, es una afirmación implícita de actualidad que el sistema no puede sostener. La diferencia entre ambas cuesta un componente pequeño y decide si el usuario confía en lo que ve.

El criterio no es técnico sino de dominio, y se formula así: ¿qué le cuesta al usuario actuar sobre un dato desactualizado? Si la respuesta es nada, un dato viejo es infinitamente mejor que una pantalla vacía. Si la respuesta es una decisión equivocada con coste real, entonces esa pantalla concreta necesita datos frescos, y lo correcto es decirlo con claridad en lugar de mostrar un número que parece actual.

Hay un segundo límite, de naturaleza distinta y que se olvida más: el espacio y la privacidad. Guardar en el dispositivo todo lo que el usuario ha visto convierte la base local en un archivo permanente de información que quizá no debería persistir, y que sobrevive al cierre de sesión si nadie lo borra explícitamente. Un diseño offline-first completo incluye una política de caducidad por tipo de dato y un borrado fiable al cerrar sesión, y ambas cosas se olvidan casi siempre hasta que alguien las pregunta en una auditoría.

Esa distinción se aplica por tipo de dato y no por aplicación entera. Una aplicación bancaria debe ser offline-first en su historial de movimientos, en sus destinatarios frecuentes y en sus categorías, y no debe serlo en el saldo disponible en el instante de confirmar un pago. Diseñar esa frontera dato a dato es más trabajo que aplicar una regla única, y es la única forma de que el resultado sea a la vez rápido y honesto.

También conviene medir el coste de adopción con realismo, porque es genuino. Aparecen un esquema local que mantener y migrar, una cola que probar, una política de conflictos por tipo de dato y una superficie nueva de estados que mostrar. Nada de eso es gratis y todo ello se paga en las primeras semanas, mientras que el beneficio se cobra a lo largo de años. Es la clase de inversión que resulta muy difícil de justificar a mitad de un proyecto y casi trivial de tomar al principio, razón por la cual la decisión de arquitectura de datos merece hacerse antes que casi ninguna otra.

Offline-first es rendimiento antes que resiliencia

La justificación con la que se suele defender este enfoque es la de la resiliencia, y aunque es cierta, es la menos importante y la que peor explica por qué el modelo se ha vuelto el estándar. El argumento decisivo es de rendimiento percibido, y es demoledor cuando se mira con números. Una lectura de una base local devuelve en el orden de unidades de milisegundos; una petición de red en una conexión móvil real devuelve entre decenas y centenares, con una cola larga que se mide en segundos y que en las redes congestionadas de las horas punta es la norma y no la excepción. Eso significa que una aplicación que lee de la red antes de pintar tiene un techo de calidad que ninguna optimización de interfaz puede levantar: da igual lo eficientes que sean las animaciones si la pantalla espera medio segundo a que llegue el contenido. Una aplicación que lee de disco pinta antes del primer fotograma y actualiza cuando llega algo nuevo, y esa diferencia no la percibe el usuario como velocidad sino como calidad, que es una categoría mucho más difícil de recuperar una vez perdida. De ahí se sigue una reinterpretación de lo que significan los indicadores de carga que conviene tomarse en serio. Un círculo girando no es una cortesía hacia el usuario: es la confesión de que el sistema no sabe qué mostrar, y la mayoría de las veces esa confesión es innecesaria porque el sistema sí sabía algo y decidió no usarlo. La pregunta que ordena todo el diseño de una capa de datos moderna no es cómo mostrar bien el estado de carga, sino por qué existe un estado de carga en una pantalla que el usuario ya había visitado antes. Cuando la respuesta es que no guardamos lo que ya sabíamos, el problema no está en la interfaz. Y hay una consecuencia final que trasciende lo técnico. Diseñar offline-first obliga a decidir de antemano qué hace el sistema en cada situación degradada, y esa obligación es la que produce aplicaciones que se comportan bien en el mundo real, donde la mitad del uso ocurre en transporte público, en edificios de hormigón y con la batería en modo de ahorro. Las aplicaciones que se diseñan para el caso feliz no es que fallen en esas condiciones: es que nunca se preguntaron qué hacer en ellas, y el comportamiento que exhiben no fue elegido por nadie.

⚔️ Invierte una pantalla completa
  1. Elige una pantalla que hoy lea de la red y reescribe su repositorio para que la lectura observe exclusivamente la base local. Mide el tiempo hasta el primer contenido antes y después.
  2. Implementa una escritura optimista con marca de pendiente y muéstrala en la interfaz. Corta la conectividad, mata el proceso desde el terminal y comprueba que la operación sigue viva al volver.
  3. Traslada la sincronización al planificador de trabajo del sistema con restricción de red y espera creciente, y verifica que sobrevive a un reinicio del dispositivo.
  4. Provoca un conflicto real modificando el mismo registro en dos dispositivos sin conexión. Documenta qué ocurre hoy y decide qué debería ocurrir.
  5. Recorre tu aplicación y clasifica cada tipo de dato según lo que le cuesta al usuario actuar sobre una versión antigua. Marca cuáles no pueden ser offline-first y escribe qué debe mostrar cada uno cuando no hay red.