La sincronización offline-first es una arquitectura donde la app lee y escribe primero en un store local y luego reconcilia con el servidor al volver online. La red deja de ser una dependencia y se convierte en un proceso de segundo plano — cada pantalla se renderiza desde el dispositivo, cada toque se confirma en el dispositivo, y una capa de sync ajusta cuentas con el backend cuando la conectividad lo permite. Lo difícil nunca fue guardar datos localmente; es lo que pasa cuando dos copias de la verdad no están de acuerdo.
Puntos clave
| Pregunta | Respuesta |
|---|---|
| El movimiento central | Las pantallas hablan con un store local en el dispositivo; la red sincroniza en segundo plano |
| Las dos posturas | Store local como buffer (el servidor es dueño de la verdad) o como réplica (la verdad se negocia) |
| Escrituras offline | Cola de outbox durable → replay en orden al reconectar |
| Conflictos | Last-write-wins · merge por campo · CRDTs — elige por campo, no por app |
| El costo honesto | La maquinaria de sync es complejidad permanente — gástala donde el offline importa |
Fijar, leer, encolar: el loop offline en código
La versión del patrón en los SDKs móviles — fija lo que las pantallas necesitan, encola lo que los usuarios cambian:
// JavaScript — Back4app JS SDK with the Local Datastore enabled
Parse.enableLocalDatastore();
// Read: serve the screen from the local store — instant, works offline
const query = new Parse.Query('Task');
query.fromLocalDatastore();
render(await query.find());
// Write: durable locally now, queued for the server automatically
const task = new Parse.Object('Task');
task.set('title', 'Inspect site 14');
task.set('done', false);
await task.pin(); // survives restarts, feeds local reads
task.saveEventually(); // replays when the network returns // Flutter / Dart — Back4app Flutter SDK with local storage enabled
// Parse().initialize(..., coreStore: await CoreStoreSembastImp.getInstance())
// Read: serve the screen from pinned data — instant, works offline
final pinned = await ParseObject('Task').fromPin('openTasks');
render(pinned);
// Write: durable locally now, synced when the network allows
final task = ParseObject('Task')
..set('title', 'Inspect site 14')
..set('done', false);
await task.pin(); // survives restarts, feeds local reads
final response = await task.save();
if (!response.success) retryLater(task); // replay from the outbox // iOS / Swift — Back4app iOS SDK with the Local Datastore enabled
// (in the app delegate) Parse.enableLocalDatastore()
// Read: serve the screen from the local store — instant, works offline
let query = PFQuery(className: "Task")
query.fromLocalDatastore()
query.findObjectsInBackground { tasks, _ in
render(tasks)
}
// Write: durable locally now, queued for the server automatically
let task = PFObject(className: "Task")
task["title"] = "Inspect site 14"
task["done"] = false
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns // Android / Kotlin — Back4app Android SDK with the Local Datastore enabled
// (before Parse.initialize) Parse.enableLocalDatastore(context)
// Read: serve the screen from the local store — instant, works offline
val query = ParseQuery.getQuery<ParseObject>("Task")
query.fromLocalDatastore()
query.findInBackground { tasks, _ ->
render(tasks)
}
// Write: durable locally now, queued for the server automatically
val task = ParseObject("Task")
task.put("title", "Inspect site 14")
task.put("done", false)
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns Dos llamadas cargan con toda la arquitectura. Fijar (pin) hace que los objetos sean durables y consultables en el dispositivo — la misma interfaz de consulta que la del servidor, apuntada al almacenamiento local (Local Datastore en los SDKs de arriba). Save-eventually acepta la escritura ahora y se hace cargo de su entrega después. Todo el resto de este artículo es lo que ocurre entre esas dos llamadas.
¿Buffer o fuente de la verdad? La decisión detrás de la decisión
Todo diseño offline-first elige en silencio una de dos posturas para el store local, y la mayor parte del dolor de sync viene de no elegirla deliberadamente.
Store local como buffer. El servidor sigue siendo autoritativo; el dispositivo guarda una copia de trabajo más un outbox de intención. Los conflictos se resuelven a favor del servidor o por una política simple, y un dispositivo siempre puede repararse con un nuevo pull. Es el default correcto para apps de negocio — checklists de CRM, inspecciones en campo, captura de pedidos — porque el razonamiento se mantiene simple: la verdad vive en un solo lugar, los dispositivos solo van rezagados.
Store local como réplica. La verdad se negocia entre pares y el servidor es una réplica más — la postura de los editores colaborativos y las apps de notas que prometen semántica de “todo se fusiona”. Esto compra resiliencia y confianza del usuario al precio de maquinaria real de sistemas distribuidos: version vectors para detectar concurrencia, funciones de merge por tipo de dato y consistencia eventual como la garantía más fuerte que puedes prometer con honestidad.
La postura se elige por conjunto de datos, no por app: la misma app de servicio en campo puede tratar las órdenes de trabajo del propio técnico como réplica (deben ser editables todo el día bajo tierra) y el catálogo de repuestos como un buffer read-through.
El loop de sync, de punta a punta
El loop tiene una mitad de salida y una de entrada, y se encuentran en la resolución de conflictos.
Salida: escrituras encoladas y replay. Las escrituras offline se confirman localmente y se agregan a un outbox durable — la interfaz refleja la intención de inmediato, etiquetada honestamente como “pendiente” donde importa. Al reconectar, la cola se reproduce en orden. La ingeniería está en los modos de falla: los errores transitorios se reintentan con backoff; los rechazos de permisos y validación deben aparecer ante el usuario, no reintentarse eternamente; y las operaciones deberían ser idempotentes, porque un acknowledgment perdido significa que la misma operación puede llegar dos veces. Reproducir “marcar estado como hecho” dos veces es inofensivo; reproducir “incrementar stock en 3” dos veces es un bug.
Entrada: pull de deltas. Volver a descargar todo al reconectar desperdicia ancho de banda y batería; el sync de producción descarga cambios desde un checkpoint — una marca de agua de updated-at en los diseños simples, un número de secuencia del servidor o un change feed en los más robustos. Los mismos eventos que alimentan las live queries cuando la app está online sirven también como stream de deltas de entrada, y por eso el sync offline y el sync en tiempo real son un continuo, no dos features.
Last-write-wins vs. merge vs. CRDTs
| Estrategia | Cómo resuelve | ¿Pierde datos? | Costo | Adecuada para |
|---|---|---|---|---|
| Last-write-wins (la última escritura gana) | El timestamp/versión más nuevo se queda con el registro | Sí — en silencio | Trivial | Registros de un solo escritor, toggles, estados |
| Merge por campo | Las ediciones en campos distintos sobreviven; las colisiones en el mismo campo caen en la política | Solo en colisiones del mismo campo | Moderado | Formularios y registros editados por pocas personas |
| Resolución manual | Se guardan ambas versiones; un humano elige | No | Interfaz + flujo de trabajo | Registros de alto riesgo, consolas de sync |
| CRDT / CRDT-lite | Tipos de dato cuyas operaciones se fusionan de forma determinista | No | Disciplina de modelado, peso de la biblioteca | Contadores, conjuntos, estructuras colaborativas |
Dos notas honestas sobre esta tabla. Primera: los timestamps son más frágiles de lo que parecen — los relojes de los dispositivos se desvían, así que un last-write-wins serio usa la hora de recepción del servidor o versiones lógicas (version vectors cuando las ediciones concurrentes deben detectarse y no adivinarse). Segunda: el punto dulce práctico para apps móviles es el CRDT-lite: las bibliotecas CRDT completas son maquinaria pesada, pero robarles la idea por campo es barato — modela una cantidad como operaciones de incremento en lugar de un total almacenado, las etiquetas como conjuntos de add/remove en lugar de un array, y esos campos simplemente dejan de entrar en conflicto. La estrategia se elige por campo; las apps que adoptan una sola política global casi siempre eligieron mal para algún campo.
Casos de uso comunes
- Operaciones en campo — inspecciones, entregas, servicios públicos: el lugar de trabajo es exactamente donde la cobertura muere, así que la captura debe ser local y el sync, oportunista.
- Notas y productividad personal — un usuario, varios dispositivos: los conflictos son raros y casi siempre autoinfligidos, lo que vuelve manejables las políticas de merge.
- Punto de venta y apps de kiosco — la fila debe seguir avanzando durante el reinicio del router; las transacciones se reproducen cuando el enlace vuelve.
- Apps de contexto de viaje — itinerarios, mapas y boletos consumidos justo donde la conectividad es peor: aviones, trenes, roaming.
- Productos para mercados emergentes y bajo ancho de banda — las conexiones intermitentes y tarifadas hacen del delta-sync en segundo plano el único diseño respetuoso, sea cual sea la mezcla de plataformas.
¿Deberías construir offline-first? Matriz de decisión
| Construye offline-first cuando… | Quédate online-first cuando… |
|---|---|
| Los usuarios trabajan donde la cobertura falla — campo, tránsito, sótanos | Los usuarios están prácticamente siempre conectados (web de escritorio, herramientas de oficina) |
| Un toque bloqueado cuesta dinero o confianza (punto de venta, captura en campo) | La corrección exige un estado autoritativo ahora — pagos, reservas, inventario |
| Los datos son registros del propio usuario con pocos escritores concurrentes | Los datos se calculan en el servidor o son compartidos y calientes (feeds, dashboards) — cachea lecturas y mantén las escrituras online |
| Las escrituras toleran una reconciliación posterior | Dos dispositivos actuando sobre una verdad vieja son un riesgo del mundo real |
| Puedes financiar el mantenimiento permanente de la capa de sync | Un estado de carga es honestamente suficiente |
El camino intermedio es legítimo y común: cachea lecturas para pantallas instantáneas, permite escrituras offline solo en los pocos conjuntos de datos que de verdad las necesitan, y mantén el resto online-first detrás de estados de conectividad honestos.
Limitaciones y trade-offs
- La resolución de conflictos es una decisión de producto disfrazada de ingeniería. “¿Qué edición sobrevive?” no tiene una respuesta técnicamente correcta — alguien debe ser dueño de la política por campo, y “quedarse en silencio con la más nueva” es una elección con víctima.
- El outbox es una cola de pasivos. Las escrituras pendientes de un dispositivo que reaparece después de dos semanas pueden reproducirse contra un mundo que cambió; las políticas de expiración y la validación en el replay son obligatorias, no paranoia.
- Los bugs de sync son la peor clase de bug que puedes comprar. Solo se reproducen bajo interleavings específicos de ediciones y conectividad — invierte en pruebas de replay determinista y en una superficie visible de estado de sync, o depura por anécdota para siempre.
- La verdad local envejece. Las pantallas deben ser honestas sobre la obsolescencia donde importa (precios, disponibilidad) — “actualizado hace 2 horas” es una feature, no una disculpa.
- El almacenamiento y la identidad agregan fricción. Los stores del dispositivo necesitan migraciones como cualquier base de datos, y los registros creados offline necesitan identidades generadas en el cliente que sobrevivan el viaje al servidor sin duplicarse.
Sincronización offline-first en Back4app
Back4app es una plataforma open-source de Backend as a Service (BaaS) que combina base de datos gestionada, APIs REST y GraphQL generadas automáticamente, autenticación, almacenamiento de archivos y funciones serverless con Cloud Code. Sus SDKs móviles traen la postura de buffer lista: el Local Datastore fija los resultados de cualquier consulta para lecturas offline con la misma API de consulta, saveEventually le da a cada escritura un outbox durable con replay ordenado, y los triggers beforeSave de Cloud Code son el hogar natural de la política de conflictos del lado del servidor — chequeos de versión, reglas de merge, validación en el replay — aplicada en cada camino de escritura. Súmale el stream de Live Query para los deltas de entrada mientras la app está online, y el loop de sync del diagrama de arriba se vuelve configuración más política, no un subsistema que construyes.
Preguntas frecuentes
¿Qué es una app offline-first?
Una app cuyas pantallas leen y escriben en una base de datos en el propio dispositivo, con la red degradada a un detalle de sincronización. Cada toque funciona en modo avión porque nada en el camino de la interacción espera a un servidor; una capa de sync en segundo plano reconcilia el store local con el backend cada vez que la conectividad lo permite. Contrasta con las apps online-first, que muestran spinners hasta que el servidor responde.
¿Cómo funciona la sincronización de datos offline?
Con dos loops. Salida: las escrituras locales caen en el store del dispositivo y en una cola de outbox durable; cuando la red vuelve, la cola se reproduce contra el servidor en orden. Entrada: el cliente descarga los cambios desde su último sync — por timestamp, número de secuencia o change feed — y los aplica localmente. La resolución de conflictos vive donde los dos loops se encuentran, decidiendo qué pasa cuando ambos lados editaron el mismo registro.
¿Qué es last-write-wins y cuándo es seguro?
La política de conflictos más simple: comparar timestamps o versiones y quedarse con la escritura más nueva, descartando la otra en silencio. Es segura cuando las ediciones son de registro completo y de bajo riesgo — un toggle de configuración, una flag de estado — o cuando lo normal es un solo escritor por registro. Es peligrosa en documentos compartidos y contadores, donde "descartar la otra edición" significa perder el trabajo de alguien.
¿Cómo resuelven conflictos los CRDTs?
Haciendo que el conflicto sea irrepresentable: un conflict-free replicated data type restringe cada campo a operaciones que se fusionan de forma determinista sin importar el orden de llegada — contadores que solo crecen, conjuntos de add/remove, registros de último escritor. Dos réplicas que intercambian estado siempre convergen sin coordinación. El costo es disciplina de modelado: tus datos deben expresarse en vocabulario CRDT, y las bibliotecas CRDT completas son maquinaria pesada para la app móvil típica de formularios y listas.
¿Qué pasa con las escrituras hechas offline?
Se encolan. Un outbox durable registra cada operación pendiente, y el store local refleja la escritura de inmediato para que la interfaz sea fiel a la intención del usuario. Al reconectar, la cola se reproduce en orden; las fallas necesitan política — reintentar errores transitorios, y devolver a la interfaz los rechazos de permisos y validación en lugar de reintentar para siempre. Las operaciones idempotentes hacen que el replay sea seguro cuando se pierde un acknowledgment.
¿Offline-first es lo mismo que caché?
No — la diferencia es la dirección. Una caché acelera lecturas: el servidor sigue siendo la fuente de la verdad y las entradas viejas simplemente se vuelven a descargar. Offline-first además acepta escrituras localmente, y eso es lo que crea divergencia entre dispositivo y servidor y obliga a la maquinaria difícil — colas de outbox, replay, resolución de conflictos. La caché de lectura es una optimización de rendimiento; offline-first es un compromiso de arquitectura de datos.
¿Cuándo no deberías construir offline-first?
Cuando la corrección depende de un único estado autoritativo: pagos, reservas, descuentos de inventario, cualquier cosa donde dos dispositivos actuando sobre datos viejos crean gastos dobles en el mundo real. También cuando los datos se calculan en el servidor y son mayormente de lectura — un feed de noticias degrada bien con una caché simple. La maquinaria de sync es un impuesto permanente; págalo donde las condiciones de campo o las expectativas del usuario lo exijan.
¿Cómo fijan datos localmente los SDKs móviles?
Fijar (pin) marca objetos para almacenamiento durable en el dispositivo: los registros fijados sobreviven reinicios, se pueden consultar offline con la misma interfaz de consulta que la del servidor y siguen ligados a sus identidades en el servidor, de modo que los syncs posteriores los actualizan en su lugar. Combinado con escrituras encoladas — la semántica de save-eventually — el pinning entrega una capa offline funcional sin armar a mano un esquema de base de datos en el dispositivo.