Arquitectura Local-First para Aplicaciones Web Progresivas
Los service workers trasladan la aplicación al dispositivo del usuario; el enfoque local-first traslada los datos. Esta única distinción explica por qué el shell de tu PWA carga instantáneamente sin conexión mientras cada lista, registro y formulario sigue girando en un fetch() que falla en el momento en que la red cae. El shell está en caché; los datos no. Residen en capas distintas, y la brecha entre ellas es donde la mayoría del “soporte offline” de las PWA falla silenciosamente.
Este artículo está dirigido a desarrolladores que ya han publicado una PWA —el manifiesto es válido, el service worker almacena activos en caché, la app se instala en la pantalla de inicio— y que sospechan que “local-first” significa algo más que el trabajo offline-first que ya han realizado. Así es. Local-first es una arquitectura de datos, no un mecanismo de tiempo de ejecución. El resto de este artículo separa esas capas con claridad, muestra cómo luce una PWA local-first como un stack al que se le añaden capas en lugar de reconstruirlo, repasa las opciones de almacenamiento y sincronización, y ofrece una guía específica para PWA sobre cuándo el patrón justifica su complejidad.
Puntos Clave
- Los service workers almacenan activos en caché y sirven el shell offline; local-first es una arquitectura de datos en la que el dispositivo mantiene la copia primaria de los datos y el servidor, cuando está presente, actúa como un par de sincronización en lugar de un guardián.
- Una PWA local-first tiene tres capas componibles: un service worker (activos y shell offline), una base de datos local (lecturas y escrituras de datos) y un motor de sincronización (consistencia eventual con el servidor) — se añaden la segunda y la tercera sin reemplazar la primera.
- IndexedDB es el punto de partida práctico para el almacenamiento local de datos; SQLite respaldado por OPFS y compilado a WebAssembly es el techo actual, proporcionando una base de datos relacional completa en el navegador.
- El último-en-escribir-gana a nivel de campo resuelve la mayoría de los conflictos en aplicaciones típicas; el texto colaborativo necesita CRDTs, y los conflictos semánticos (dos usuarios reservando la misma sala) requieren validación del lado del servidor durante la sincronización.
- Local-first es adecuado para PWA instalables de toma de notas, servicios de campo y edición colaborativa; no aporta nada a dashboards de datos generados por el servidor ni a nada sujeto a garantías ACID.
Por Qué los Datos de tu PWA Siguen Dependiendo de la Red
Una PWA con un shell en caché pero una capa de datos dependiente de fetch() es offline solo en el sentido más estricto: el chrome carga sin red y luego cada componente que necesita datos se detiene. El service worker puede reproducir el HTML, CSS y JavaScript desde la Cache API sin tocar la red, por lo que la app se renderiza sin conexión. Pero los datos que muestran esos componentes siguen proviniendo de una solicitud de red, y un ServiceWorker que intercepta un fetch() de datos dinámicos del usuario no tiene nada útil que devolver cuando esos datos nunca se han almacenado en caché o han cambiado desde entonces.
Esta es la razón estructural por la que el trabajo offline-first en PWA se detiene en el shell. Las estrategias de caché —cache-first, network-first, stale-while-revalidate— responden a qué versión de un activo servir y qué tan desactualizada puede estar. No responden a dónde viven los datos del usuario ni quién posee la copia autoritativa. La Cache API almacena respuestas HTTP indexadas por solicitud. No es una base de datos que puedas consultar, en la que escribir y de la que leer dentro de un componente. Para cerrar la brecha, necesitas un almacén de datos local real y una estrategia para mantenerlo consistente con el servidor. Eso es local-first.
Local-First No Es Offline-First, y Ninguno de los Dos Es un Service Worker
Local-first, offline-first, PWA y caché de service worker son cuatro conceptos distintos que se complementan pero no son intercambiables. Confundirlos es el error más común en este ámbito.
| Término | Qué es | En qué capa opera |
|---|---|---|
| PWA | Un modelo de distribución: app web instalable, basada en manifiesto, con capacidad push | Empaquetado |
| Service worker | Un mecanismo de tiempo de ejecución que intercepta solicitudes y sirve activos en caché | Red / tiempo de ejecución |
| Offline-first | Un objetivo de diseño: degradar con elegancia cuando la red no está disponible, con el servidor aún como fuente de verdad | Comportamiento |
| Local-first | Una arquitectura de datos: el dispositivo mantiene la copia primaria; el servidor es un par de sincronización | Datos |
Offline-first significa que la aplicación gestiona la pérdida de red con elegancia, pero el servidor sigue siendo la fuente de verdad: la caché local es una copia de conveniencia que se subordina al servidor remoto. Local-first invierte esa relación: el dispositivo del usuario mantiene la copia primaria de los datos, la aplicación lee y escribe en una base de datos local, y el servidor, cuando está presente, es un nodo más entre varios que se reconcilian en segundo plano. La separación clara importa porque indica qué se puede reutilizar. Tu service worker y tu framework no cambian. Estás añadiendo una capa de datos por debajo de ellos.
El recorrido local-first de Plainvanilla integra el backend-for-frontend dentro del service worker como parte de la arquitectura. Esa es una implementación posible, no la definición. Mantén las capas separadas: local-first trata sobre dónde viven los datos y qué copia es autoritativa; el service worker es un mecanismo que casualmente está disponible en el mismo entorno de ejecución.
Discover how at OpenReplay.com.
Qué Significa Realmente Local-First
Local-first es una arquitectura de datos en la que el dispositivo del usuario mantiene la copia primaria de los datos de la aplicación, la app lee y escribe en una base de datos local, y el servidor, cuando está presente, es un par de sincronización con autoridad especial en lugar de un guardián que debe aprobar cada lectura y escritura. La aplicación lee y escribe en una base de datos local de forma síncrona desde la perspectiva del usuario, y la sincronización con el servidor u otros dispositivos ocurre de forma asíncrona en segundo plano. El cambio arquitectónico es una reclasificación del cliente: deja de ser una vista delgada que solicita permiso para mostrar datos y se convierte en un participante pleno que posee una réplica.
La referencia canónica es el ensayo de 2019 Local-First Software: You Own Your Data, in Spite of the Cloud de Ink & Switch, que establece siete ideales: rápido, multi-dispositivo, offline, colaborativo, duradero, privado y controlado por el usuario. La propiedad que cambia cómo escribes código es la primera: dado que las lecturas y escrituras van a una base de datos local, no existe un flag isLoading en una lectura y las escrituras pueden actualizar el estado local de inmediato. La escritura local es el estado, mientras que la sincronización y la resolución de conflictos ocurren en segundo plano.
En el código de componentes, esto colapsa la danza de fetch-y-caché en una consulta directa. En lugar de un useQuery que devuelve { data, isLoading, error }, una consulta local-first se suscribe a la base de datos local y se vuelve a renderizar cuando cambia:
// Local-first: la consulta lee la BD local y se re-renderiza al cambiar.
// Sin isLoading, sin error boundary para la lectura, sin invalidación de caché.
function TaskList({ projectId }) {
const tasks = useLiveTasks(projectId); // se suscribe a la BD local
return (
<ul>
{tasks.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
El hook useLiveTasks lo proporciona el motor de sincronización o la capa de consulta que elijas; la forma es lo que importa. Una escritura es una inserción directa contra el almacén local, la suscripción se activa y la UI se actualiza. La sincronización con el servidor ocurre cuando la red lo permite.
Las Tres Capas de una PWA Local-First
Una PWA local-first tiene tres capas componibles: un service worker que almacena activos en caché y sirve el shell offline, una base de datos local (IndexedDB o SQLite respaldado por OPFS) que contiene los datos del usuario y sirve todas las lecturas y escrituras, y un motor de sincronización que reconcilia la base de datos local con el servidor en segundo plano. Cada capa tiene una responsabilidad y desconoce los detalles internos de las demás.
| Capa | Rol | Gestiona | NO gestiona |
|---|---|---|---|
| Capa 3 — Motor de sincronización | Reconcilia la base de datos local con el servidor. | Resolución de conflictos, consistencia eventual, push/pull en segundo plano. | Renderizado, caché de activos. |
| Capa 2 — Base de datos local | Contiene los datos del usuario. IndexedDB o SQLite respaldado por OPFS (WASM). | Todas las lecturas y escrituras que realiza la UI. | La red, el shell offline. |
| Capa 1 — Service worker | Almacena activos en caché y sirve el shell offline. | Caché de activos, servir el shell offline, ciclo de vida install/activate, push. | Datos del usuario. |
Adoptar local-first en una PWA existente no requiere reemplazar el service worker ni el framework de la aplicación: requiere añadir una capa de datos desde la que la aplicación lee y escribe localmente, y un motor de sincronización que mantenga esa capa consistente con el servidor. La Capa 1 ya existe en tu PWA. Estás introduciendo las Capas 2 y 3 por debajo de los componentes que actualmente llaman a fetch(), y reconectando esos componentes para que lean desde la base de datos local.
Dónde Viven los Datos: Introducción al Almacenamiento Local
localStorage no es la respuesta. Es síncrono, por lo que bloquea el hilo principal; almacena solo cadenas de texto; y según la documentación de la Web Storage API en MDN, su capacidad es pequeña y específica del navegador: adecuado para una preferencia de tema, no apto para una capa de datos.
IndexedDB es el punto de partida: asíncrono, disponible en todos los navegadores modernos y capaz de almacenar mucho más que localStorage, aunque la cuota de almacenamiento está basada en el origen y es específica del navegador en lugar de un límite fijo. Su API nativa es de bajo nivel y verbosa: una sola escritura requiere abrir una transacción, apuntar a un object store y conectar callbacks de solicitud, razón por la que la mayoría de las aplicaciones recurren a una librería de abstracción.
El techo es SQLite compilado a WebAssembly, persistido en el Origin Private File System (OPFS). SQLite respaldado por OPFS proporciona una base de datos relacional completa en el navegador: transacciones, índices y una interfaz SQL familiar ejecutándose localmente en el cliente. OPFS admite acceso a archivos síncrono de alto rendimiento a través de createSyncAccessHandle(), que solo está disponible dentro de un Web Worker, exactamente el patrón de acceso que SQLite necesita. La distribución oficial de SQLite para WebAssembly documenta OPFS como un backend de persistencia compatible.
En código, una escritura contra SQLite-sobre-WASM se lee como SQL del lado del servidor: una sola sentencia contra un almacén relacional:
// SQLite (WASM) mediante el paquete oficial sqlite-wasm: SQL estándar.
await db.exec({
sql: "INSERT INTO tasks (id, title, done) VALUES (?, ?, 0)",
bind: [crypto.randomUUID(), "Review draft"],
});
La compatibilidad de los navegadores con OPFS es amplia a finales de 2025 — consulta la tabla de compatibilidad de MDN para la File System API para conocer el estado actual por navegador, que es la referencia que debes consultar en lugar de una lista de versiones fija, ya que cambia. Un problema habitual en producción es que el comportamiento de OPFS puede diferir sutilmente entre motores de navegador y contextos de integración, por lo que mantener una ruta de respaldo basada en IndexedDB sigue siendo recomendable para navegadores y entornos donde la compatibilidad con OPFS no está disponible, es limitada o se comporta de manera diferente a tu plataforma de destino principal.
El Panorama de los Motores de Sincronización
El motor de sincronización es la capa que convierte local-first en algo más que offline-first: reconcilia la réplica local con el servidor y otros dispositivos. No necesitas construirlo desde cero. Varios motores, tanto en producción como emergentes, ocupan distintos nichos, y el más adecuado depende de la forma de tu aplicación y tu backend existente. No existe un estándar web para la sincronización, por lo que cada motor define su propio protocolo; mantén la capa de sincronización abstraída para que sea factible cambiar de motor.
- PowerSync — replica un backend de Postgres en una base de datos SQLite del cliente con un camino de escritura de vuelta; encaja perfectamente cuando ya usas Postgres y quieres soporte offline sin rediseñar el servidor.
- Electric — un motor de sincronización para Postgres construido en torno a “Shapes” que definen qué datos recibe cada cliente. Encaja bien para aplicaciones que necesitan replicación parcial y sincronización de datos por usuario sobre un backend Postgres existente.
- Replicache y Zero (ambos de Rocicorp) — sistemas de sincronización basados en consultas que priorizan las experiencias de usuario local-first sobre la replicación a nivel de fila. Encajan bien para aplicaciones construidas en torno a mutaciones del lado del cliente, actualizaciones optimistas y reconciliación del lado del servidor.
- Triplit — una base de datos full-stack con sincronización integrada, de modo que la base de datos del cliente y del servidor son un único modelo mental en lugar de dos; se adapta a proyectos nuevos que quieren sincronización por defecto.
- Yjs y Automerge — librerías CRDT para edición colaborativa; la elección correcta cuando múltiples usuarios editan el mismo texto enriquecido o documento estructurado de forma concurrente y necesitas fusión a nivel de carácter.
Para la mayoría de las PWA de línea de negocio que sincronizan registros propiedad del usuario, un motor de replicación de filas sobre tu base de datos existente encaja mejor que una librería CRDT. Recurre a Yjs o Automerge específicamente cuando el texto colaborativo en tiempo real es el producto, no como solución general de conflictos.
Resolución de Conflictos
Cuando dos réplicas modifican los mismos datos sin ver los cambios de la otra, el motor de sincronización debe reconciliarlos. Los conflictos se dividen en tres categorías, cada una con una estrategia de resolución diferente.
-
Conflictos estructurales — último-en-escribir-gana a nivel de campo. El último-en-escribir-gana a nivel de campo gestiona la mayoría de los conflictos en aplicaciones típicas: si dos usuarios editan campos distintos del mismo registro sin conexión, ambos cambios sobreviven; si editan el mismo campo, gana la marca de tiempo más reciente. Esta es una regla general ampliamente utilizada en la comunidad local-first más que una constante medida — Designing Data-Intensive Applications de Martin Kleppmann cubre en profundidad las compensaciones de consistencia del último-en-escribir-gana.
-
Conflictos en texto colaborativo — CRDTs. Cuando dos usuarios escriben en el mismo párrafo, el último-en-escribir-gana descarta las pulsaciones de teclas de una persona. Los tipos de datos replicados sin conflictos (Conflict-free Replicated Data Types) fusionan ediciones concurrentes a nivel de carácter para que ambos conjuntos de caracteres aparezcan de forma coherente. Yjs y Automerge implementan esto; los mecanismos de fusión difieren, pero la garantía es la misma: las ediciones concurrentes convergen sin un coordinador central.
-
Conflictos semánticos — validación del lado del servidor. Algunos conflictos se fusionan correctamente a nivel estructural pero producen un resultado que viola un invariante de dominio. Ejemplo: dos usuarios sin conexión reservan la misma sala de reuniones para las 14:00. Ambas escrituras apuntan a registros distintos, por lo que una fusión a nivel de campo acepta ambas: estructuralmente válido, pero una doble reserva. Los conflictos semánticos, donde los datos se fusionan correctamente pero el resultado viola un invariante de dominio, requieren validación del lado del servidor durante la sincronización. El patrón que evita la pérdida de datos es aceptar la escritura conflictiva, marcar la violación y presentarla al usuario como una notificación resoluble, en lugar de rechazarla silenciosamente: el rechazo deja al cliente con registros que el servidor se niega a reconocer.
Los bugs más difíciles en las PWA local-first no son fallos de sincronización, que aparecen en los logs. Son sobrescrituras silenciosas: una escritura optimista tiene éxito localmente, la sincronización se completa sin error y el cambio del usuario es reemplazado silenciosamente por una versión remota que ganó la carrera del último-en-escribir-gana. El fallo es invisible para los logs del servidor, el monitoreo de errores y los trazados de red; solo se vuelve legible en una herramienta que registra la secuencia de estados de la UI: la actualización optimista y la reversión silenciosa que la sigue. La reproducción de sesiones es uno de los pocos lugares donde esa discrepancia aparece como una secuencia observable en lugar de una línea de log limpia.
Cuándo Local-First Encaja en tu PWA Específicamente
Local-first encaja en una PWA cuando los datos que gestiona la app son propiedad del usuario y se benefician de la interacción local instantánea; no aporta nada cuando los datos son generados por el servidor o están sujetos a garantías transaccionales fuertes. La pregunta determinante no es la categoría de la app en abstracto, sino si los datos pertenecen al dispositivo.
| Caso de uso de PWA | ¿Ayuda local-first? | Por qué |
|---|---|---|
| App instalable de toma de notas | Sí | Datos propiedad del usuario, lecturas/escrituras instantáneas, la edición offline es el valor central; el shell instalable y los datos locales se refuerzan mutuamente. |
| App de servicio de campo con conectividad inestable | Sí | El trabajo ocurre donde la red es débil; las escrituras deben tener éxito sin conexión y sincronizarse cuando hay cobertura. |
| Herramienta de edición colaborativa | Sí | La edición concurrente es el producto; la sincronización respaldada por CRDT ofrece fusión en tiempo real sin un viaje de ida y vuelta por pulsación de tecla. |
| Dashboard de analíticas | No | El servidor genera los datos; almacenar el shell en caché es útil, pero local-first no aporta nada a datos que el usuario no posee ni modifica. |
| Checkout de comercio electrónico | No | Los pagos y el inventario necesitan garantías ACID y una única base de datos autoritativa; la consistencia eventual puede sobrevender stock o cobrar dos veces. |
| Feed social | No | El feed es ordenado y propiedad del servidor; el cliente lo consume en lugar de crearlo. |
El patrón se alinea con las fortalezas de las PWA exactamente donde el usuario crea y posee los datos. Para una PWA de toma de notas, el shell instalable, la escritura offline y la sincronización en segundo plano se combinan en una experiencia cercana a una app nativa. Para un dashboard, el service worker puede almacenar el shell en caché para una carga más rápida, pero los números siguen viniendo del servidor: local-first resuelve un problema que ese caso de uso no tiene.
Añades una Capa de Datos, No Reconstruyes la App
Incorporar local-first a una PWA existente significa añadir dos capas, no reconstruir lo que tienes: una base de datos local y un slot para el motor de sincronización por debajo de los componentes que actualmente giran en fetch(), mientras el service worker y el framework permanecen donde están. No estás reemplazando el trabajo de PWA que ya has realizado, lo estás completando, trasladando los datos al usuario de la misma manera que el service worker ya trasladó la app. El siguiente paso concreto es pequeño: elige una funcionalidad cuyos datos pertenezcan al usuario y que este edite, respáldala con IndexedDB o SQLite respaldado por OPFS, conecta su componente para que lea desde ese almacén y deja que un motor de sincronización lo reconcilie en segundo plano. Esa única funcionalidad te dirá, más rápido que cualquier lectura adicional, si el resto de tu app también pertenece ahí.
Preguntas Frecuentes
Generalmente sí, pero su rol cambia. En una arquitectura local-first, el servidor deja de ser un guardián que aprueba cada lectura y escritura y se convierte en un par de sincronización que reconcilia la base de datos local entre dispositivos, persiste una copia duradera y aplica validación del lado del servidor para conflictos semánticos como las dobles reservas. Las apps que necesitan garantías ACID, pagos o estado compartido autoritativo siguen requiriendo un backend real; solo el camino de lectura y escritura a través de la UI se traslada a la base de datos local.
No. Operan en capas distintas y se complementan en lugar de competir. El service worker almacena activos en caché y sirve el shell offline; local-first añade una base de datos local y un motor de sincronización por debajo de los componentes que actualmente llaman a fetch. Incorporar local-first a una PWA existente significa mantener el service worker y el framework en su lugar e introducir las capas de datos y sincronización por debajo de ellos, no reescribir la Capa 1.
Elige CRDTs cuando la edición concurrente del mismo texto enriquecido o documento estructurado es el producto en sí mismo, porque el último-en-escribir-gana descarta las pulsaciones de teclas de un usuario cuando dos personas escriben en el mismo párrafo. Los tipos de datos replicados sin conflictos fusionan ediciones concurrentes a nivel de carácter para que ambos conjuntos de caracteres converjan sin un coordinador central. Para la mayoría de las PWA de línea de negocio que sincronizan registros propiedad del usuario, el último-en-escribir-gana a nivel de campo sobre un motor de replicación de filas es la opción más adecuada; reserva Yjs o Automerge para texto colaborativo en tiempo real.
Esto es una sobrescritura silenciosa. Una escritura optimista tiene éxito localmente, la sincronización se completa sin error y una versión remota gana la carrera del último-en-escribir-gana, reemplazando silenciosamente el cambio local. El fallo es invisible para los logs del servidor, que muestran éxito; para el monitoreo de errores, que no lanza ninguna excepción; y para los trazados de red, que muestran que la solicitud se procesó. Solo se vuelve legible en una herramienta que registra la secuencia de estados de la UI, capturando tanto la actualización optimista como la reversión que la sigue.
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.