Agregar un manifest.json y un Service Worker que cachea el index.html no convierte una app web en algo que funciona de verdad sin conexión. La parte difícil no es cachear el "app shell" (HTML, CSS, JS, íconos) — eso es prácticamente automático con Workbox o un Service Worker básico. Lo difícil es decidir qué hacer con los datos: qué se puede mostrar desde caché, qué operaciones se pueden encolar para más tarde, y qué simplemente no se puede hacer sin conexión. Esto lo he tenido que resolver en PWAs de inventario médico y de gestión de viáticos que se usan en almacenes y en campo, donde el WiFi es intermitente por diseño.
Dos tipos de contenido, dos estrategias de caché
El app shell (los archivos que casi nunca cambian) se beneficia de una estrategia cache-first: el Service Worker sirve la versión en caché inmediatamente y, en paralelo, revisa si hay una versión nueva en la red para actualizar la caché de cara a la siguiente carga. Es rápido y funciona sin conexión desde el primer segundo.
Los datos dinámicos (existencias de un almacén, el estatus de una requisición, el saldo de un presupuesto) necesitan lo contrario: network-first con fallback a caché. Si hay red, siempre se pide el dato fresco; si falla, se muestra la última copia conocida con un aviso visible de que puede estar desactualizada. Servir datos de stock desde una caché de hace tres días sin avisarlo es la forma más rápida de que un auxiliar de almacén despache un producto que ya no existe.
localStorage no es una base de datos
localStorage es síncrono (bloquea el hilo principal), solo guarda strings, y en la práctica tiene un límite de unos 5MB por origen. Sirve para banderas simples — "el usuario ya aceptó el aviso de cookies", "el tour de la app ya se mostró" — pero no para colas de operaciones pendientes con estructura.
IndexedDB es asíncrono, soporta objetos estructurados (no solo strings) e índices para consultar por campo, y tiene un límite de almacenamiento mucho mayor (varios cientos de MB, dependiendo del navegador y espacio en disco). Cuando un auxiliar registra un movimiento de inventario o un empleado captura un gasto con foto sin conexión, ese registro — con su foto en base64 o blob incluido — se guarda en IndexedDB con un campo estado: 'pendiente', no en localStorage.
La cola de sincronización: el patrón que realmente importa
El flujo que uso es siempre el mismo, independientemente del dominio (inventario, gastos, GPS):
- El usuario hace una acción (escanea un producto, sube un gasto, registra una visita). Si hay red, se envía directo al backend y se marca
sincronizado. - Si no hay red (o el
fetchfalla por timeout), la operación se guarda en IndexedDB conestado: 'pendiente'y unidgenerado localmente (UUID, nunca un folio consecutivo — eso lo asigna el servidor al recibir el dato, para evitar colisiones entre dispositivos). - Al detectar el evento
onlinedel navegador (o, donde está disponible, la Background Sync API), se recorren los registros pendientes en orden y se reintentan uno por uno. - Cada envío exitoso actualiza el registro local a
sincronizadoy guarda elidreal que devolvió el servidor, para poder enlazarlo con fotos u otros datos relacionados que se suban después.
Un detalle que cuesta caro si se ignora: Background Sync API no existe en Safari/iOS. Si tu base de usuarios usa iPhones (muy común en fuerza de ventas y personal médico), no puedes depender solo de esa API — necesitas el fallback manual con el evento online y, si quieres cubrir el caso de que el usuario cierre la pestaña antes de reconectar, un reintento también al DOMContentLoaded de la siguiente sesión.
Reintentos con backoff, no en bucle
Reintentar inmediatamente y sin límite cuando la red vuelve intermitentemente (una barra de señal que sube y baja) puede saturar el backend con ráfagas de requests fallidos. Un backoff exponencial simple (1s, 2s, 4s, 8s… con un tope) evita eso y es sencillo de implementar con setTimeout recursivo, sin necesitar ninguna librería.
Degradar con gracia: el caso del escaneo de códigos
En una PWA de inventario con escaneo de códigos GS1-128/DataMatrix, uso BarcodeDetector nativo cuando el navegador lo soporta (Chrome/Android principalmente) y caigo a ZXing vía JavaScript cuando no (Safari/iOS, navegadores más viejos). Esa misma lógica de "usa lo nativo si existe, cae a una librería si no" es el mismo principio que offline-first: la app debe seguir siendo útil aunque falte una capacidad del entorno, en vez de mostrar una pantalla en blanco o un error genérico.
Conclusión
Offline-first no es una casilla que se marca agregando un Service Worker — es una decisión de diseño que hay que tomar dato por dato: qué se puede cachear, qué se puede encolar, y qué simplemente requiere avisarle al usuario que espere a tener señal. Ignorar esa distinción es la diferencia entre una PWA que de verdad funciona en un almacén sin WiFi confiable y una que solo parece funcionar en la demo con buena conexión.