Las aplicaciones de página única clásicas (SPA) sufren una vulnerabilidad arquitectónica crítica: dependen íntegramente de una conectividad a Internet ininterrumpida. Al ingresar a un ascensor, transitar por un túnel subterráneo, operar en naves logísticas blindadas o lidiar con conexiones 3G inestables ("Lie-Fi"), el usuario queda atrapado ante pantallas en blanco, spinners infinitos, peticiones HTTP fallidas y la pérdida irreparable de formularios no guardados.
La arquitectura Progressive Web Apps (PWA) transiciona la aplicación hacia el paradigma Offline-First. En este modelo, el almacenamiento local y la caché del dispositivo son la fuente primaria de verdad, mientras que la red remota actúa como un canal de sincronización asíncrono.
El núcleo vertebrador de una PWA es el Service Worker. Intercepta el tráfico saliente, sirve el shell de la aplicación prealmacenado en tiempo constante O(1) y garantiza la ejecución fiable de mutaciones diferidas mediante el Background Sync API.
Un Service Worker opera en un hilo aislado del navegador, no bloquea el Main Thread, carece de acceso directo al DOM y sigue ejecutándose incluso tras cerrar todas las pestañas de la aplicación. Al interceptar la pila de red, implementa estrategias deterministas de caché (Cache-First, Stale-While-Revalidate, Network-First), asegurando un arranque instantáneo de la interfaz (Time to Interactive < 50 ms) con independencia del estado de la red física.
Conceptualmente, un Service Worker opera como un servidor proxy perimetral local (Local Reverse Proxy / Gateway) ejecutado directamente en el hardware del usuario entre el código JavaScript de React y la tarjeta física de red:
┌──────────────────────────────────────────────────────────────┐
│ REACT 19 SPA (Main Thread) │
│ fetch('/api/catalog') │ fetch('/assets/bundle.js') │
└──────────────────────────────┬───────────────────────────────┘
│ Interceptación de red
▼ (Evento self.onfetch)
┌──────────────────────────────────────────────────────────────┐
│ SERVICE WORKER (Programmable Network Proxy) │
│ │
│ ┌─────────────────────────┐ ┌────────────────────────┐ │
│ │ Workbox Router │ │ Background Sync Queue │ │
│ │ - Cache-First (Static) │ │ (Mutaciones diferidas │ │
│ │ - Stale-While-Rev (API)│ │ en IndexedDB) │ │
│ └────────────┬────────────┘ └───────────┬────────────┘ │
└───────────────┼──────────────────────────────┼───────────────┘
│ │
┌───────┴────────┐ │
▼ ▼ ▼
┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ Cache Storage │ │ Internet │ │ Remote API │
│ (Disco: 0–5ms)│ │ (Remote CDN) │ │ (Con red) │
└───────────────┘ └──────────────┘ └──────────────┘A diferencia de la caché HTTP convencional del navegador sujeta a cabeceras Cache-Control del servidor, Cache Storage queda bajo el control exclusivo del código del Service Worker.
Un Service Worker está gobernado por una máquina de estados finitos estricta:
scope).install. En esta fase se descarga y almacena en caché el esqueleto crítico de la aplicación (App Shell: HTML, paquetes críticos JS/CSS, tipografías).activate. El worker efectúa la rotación de cachés: purga versiones obsoletas de despliegues previos y asume el control de las pestañas activas.fetch, el retorno de la red activa sync y los mensajes del servidor activan push.El cliente invoca navigator.serviceWorker.register('/sw.js'). El navegador descarga el archivo y calcula su Scope de ejecución.
El evento self.oninstall descarga el App Shell. Invocar self.skipWaiting() fuerza la activación sin esperar al cierre de pestañas.
El evento self.onactivate elimina cachés antiguas (v1 -> v2). La llamada a self.clients.claim() toma el control de las páginas abiertas.
self.onfetch distribuye peticiones según Workbox y self.onsync procesa las mutaciones acumuladas al recuperar la conectividad.
Se despliega una nueva versión de la aplicación y se actualiza sw.js en producción. Un usuario mantiene abiertas tres pestañas de la app. ¿Qué ocurre con la nueva versión del Service Worker si en el evento install NO se invocó self.skipWaiting()?
Operarios de almacenes y repartidores trabajan en hangares de hormigón, sótanos o montacargas con pérdidas recurrentes de cobertura. Los albaranes y códigos de barras se registran en local en IndexedDB y el Service Worker, mediante el Background Sync API, garantiza el envío ordenado a los servidores en cuanto se recupera la señal Wi-Fi.
En conexiones móviles lentas (3G o 4G deficiente), cada segundo de espera reduce la conversión un 7–10%. El precaché del CSS crítico, tipografías y plantillas del catálogo con entrega desde Cache Storage en 0–5 ms otorga una fluidez nativa y retiene el carrito de compra ante caídas de red.
Pasajeros en vuelos o en el metro pueden descargar artículos y podcasts en Cache Storage con antelación. La aplicación detecta el estado de red con hooks reactivos y conmuta la interfaz a un catálogo de contenido disponible offline.
Portales empresariales que utilizan la estrategia Stale-While-Revalidate para recursos estáticos y tablas maestras arrancan de forma instantánea incluso bajo pasarelas VPN corporativas de alta latencia (> 300 ms RTT).
La librería Workbox de Google es el estándar industrial para definir políticas declarativas de caché y enrutamiento. Existen cinco estrategias principales:
1. Cache-First (Máxima velocidad):
Request ──► [Cache Storage] ──(Hit)──► Return (0-5ms)
│
(Miss)
▼
[Network] ──► Save to Cache ──► Return
2. Stale-While-Revalidate (Equilibrio frescura/latencia):
Request ──► [Cache Storage] ──(Hit)──► Return immediately
│
(Petición paralela en background)
▼
[Network] ──► Update Cache in background
3. Network-First (Prioridad datos frescos):
Request ──► [Network (timeout: 3s)] ──(Success)──► Update Cache ──► Return
│
(Timeout / Offline)
▼
[Cache Storage] ──(Hit)──► Return stale dataCada petición de red (CSS, JS, fetch API) es capturada por self.onfetch. Se valida el método HTTP y la URL de destino.
Los recursos estáticos de CDN se desvían a Cache-First; las rutas /api/* van a Stale-While-Revalidate con ExpirationPlugin.
Ante pérdidas de red y fallos de caché en navegación HTML, se devuelve la vista precompilada /offline.html.
Las mutaciones POST/PUT fallidas se depositan mediante BackgroundSyncPlugin en IndexedDB hasta recuperar la conexión.
Está desarrollando la PWA de una tienda online. Las fichas del catálogo de productos deben mostrarse de forma instantánea al abrir la app (0–5 ms), pero los precios y el stock deben sincronizarse en segundo plano cuando haya cobertura. ¿Qué estrategia de Workbox es la adecuada?
A continuación se presenta la infraestructura PWA completa y preparada para producción en React 19:
public/sw.js: Service Worker industrial con Workbox, estrategias de caché, Background Sync y control de SKIP_WAITING.src/hooks/useServiceWorkerUpdate.ts: Hook reactivo para detectar actualizaciones pendientes y forzar la recarga coordinada.src/hooks/useOnlineStatus.ts: Hook de conectividad basado en useSyncExternalStore libre de desajustes de hidratación SSR.src/components/pwa/PwaOfflineManager.tsx: Componente de UI con diálogos de instalación, aviso de estado offline y banner de actualización.// Service Worker: Fullstack Agentic Lab Enterprise PWA Core v2.4.0
// Emplea Workbox desde el CDN oficial de Google
importScripts('https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js');
if (workbox) {
console.log('[SW]: Workbox inicializado correctamente.');
// Asumir el control de clientes de forma inmediata
workbox.core.clientsClaim();
// 1. Precaché del App Shell esencial
const PRECACHE_ASSETS = [
{ url: '/', revision: '2026-v2-core' },
{ url: '/manifest.webmanifest', revision: '2026-v2-manifest' },
{ url: '/offline.html', revision: '2026-v2-offline-page' },
];
workbox.precaching.precacheAndRoute(PRECACHE_ASSETS);
// 2. Estrategia Cache-First para estáticos inmutables: tipografías, estilos, scripts e imágenes
workbox.routing.registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new workbox.strategies.CacheFirst({
cacheName: 'agenticlab-static-assets-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [0, 200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 días
purgeOnQuotaError: true,
}),
],
})
);
// 3. Estrategia Stale-While-Revalidate para peticiones GET a endpoints de API
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/') && request.method === 'GET',
new workbox.strategies.StaleWhileRevalidate({
cacheName: 'agenticlab-api-cache-v2',
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
new workbox.expiration.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 24 * 60 * 60, // 24 horas
}),
],
})
);
// 4. Estrategia Network-First con timeout corto para navegación HTML
const navigationHandler = new workbox.strategies.NetworkFirst({
cacheName: 'agenticlab-pages-cache-v2',
networkTimeoutSeconds: 3,
plugins: [
new workbox.cacheableResponse.CacheableResponsePlugin({
statuses: [200],
}),
],
});
workbox.routing.registerRoute(({ request }) => request.mode === 'navigate', async (params) => {
try {
return await navigationHandler.handle(params);
} catch {
// Si la red falla y no hay caché, se devuelve la página de contingencia offline
return (await caches.match('/offline.html')) || Response.error();
}
});
// 5. Background Sync: envío diferido garantizado de mutaciones cuando vuelva la red
const bgSyncPlugin = new workbox.backgroundSync.BackgroundSyncPlugin('offline-mutations-queue', {
maxRetentionTime: 24 * 60, // Conservar durante 24 horas (en minutos)
});
workbox.routing.registerRoute(
({ url, request }) => url.pathname.startsWith('/api/mutations/') && request.method === 'POST',
new workbox.strategies.NetworkOnly({
plugins: [bgSyncPlugin],
}),
'POST'
);
// 6. Escucha de mensaje interproceso para actualización forzada
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
console.log('[SW]: Comando SKIP_WAITING recibido. Activando nuevo worker...');
self.skipWaiting();
}
});
} else {
console.error('[SW]: Error al cargar scripts de Workbox.');
}'use client';
import * as React from 'react';
/**
* Suscripción a eventos nativos del navegador de cambio de conexión
*/
function subscribe(callback: () => void): () => void {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot(): boolean {
return navigator.onLine;
}
function getServerSnapshot(): boolean {
// En el servidor siempre asumimos conectividad para evitar desajustes de hidratación
return true;
}
/**
* Hook para monitorizar el estado de la conexión a Internet
* @returns boolean: true si hay red, false si el dispositivo está desconectado
*/
export function useOnlineStatus(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}'use client';
import * as React from 'react';
interface ServiceWorkerUpdateState {
isUpdateAvailable: boolean;
registration: ServiceWorkerRegistration | null;
updateApp: () => void;
}
/**
* Hook para la gestión del ciclo de vida y actualización del Service Worker
*/
export function useServiceWorkerUpdate(): ServiceWorkerUpdateState {
const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false);
const [registration, setRegistration] = React.useState<ServiceWorkerRegistration | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
let refreshing = false;
// Recargar la página en cuanto el nuevo controlador tome posesión
const handleControllerChange = () => {
if (!refreshing) {
refreshing = true;
window.location.reload();
}
};
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
navigator.serviceWorker
.register('/sw.js')
.then((reg) => {
setRegistration(reg);
// Comprobar si ya existe una versión nueva en espera
if (reg.waiting) {
setIsUpdateAvailable(true);
}
// Escuchar la detección de nuevas versiones del archivo sw.js
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
// El nuevo worker se ha instalado pero el previo sigue controlando la página
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
setIsUpdateAvailable(true);
}
});
});
})
.catch((err) => {
console.error('[SW Registration Error]:', err);
});
return () => {
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
};
}, []);
const updateApp = React.useCallback(() => {
if (registration?.waiting) {
// Transmitir orden SKIP_WAITING al worker que se encuentra en espera
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}, [registration]);
return { isUpdateAvailable, registration, updateApp };
}'use client';
import * as React from 'react';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
import { useServiceWorkerUpdate } from '@/hooks/useServiceWorkerUpdate';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PwaOfflineManager() {
const isOnline = useOnlineStatus();
const { isUpdateAvailable, updateApp } = useServiceWorkerUpdate();
const [installPrompt, setInstallPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
const [isStandalone, setIsStandalone] = React.useState(false);
React.useEffect(() => {
// Detectar si la aplicación ya se ejecuta en modo standalone
const checkStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as unknown as { standalone?: boolean }).standalone === true;
setIsStandalone(checkStandalone);
// Interceptar la solicitud nativa de instalación del navegador
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', () => {
setIsStandalone(true);
setInstallPrompt(null);
});
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
};
}, []);
const handleInstallClick = async () => {
if (!installPrompt) return;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsStandalone(true);
}
setInstallPrompt(null);
};
return (
<div aria-live="polite" className="pointer-events-none fixed inset-x-0 bottom-0 z-50 p-4 flex flex-col gap-3 items-end">
{/* 1. Alerta de desconexión de red */}
{!isOnline && (
<div className="pointer-events-auto w-full max-w-md p-3.5 rounded-xl bg-red-950/95 border border-red-800 text-red-100 text-xs flex items-center justify-between shadow-2xl backdrop-blur-md animate-fade-in">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-ping shrink-0" />
<span>Está navegando sin conexión. La app funciona desde la caché local; las acciones se conservan.</span>
</div>
<span className="font-mono text-[10px] uppercase font-bold text-red-400 bg-red-900/60 px-2 py-0.5 rounded">
Offline
</span>
</div>
)}
{/* 2. Aviso de nueva versión disponible */}
{isUpdateAvailable && (
<div className="pointer-events-auto w-full max-w-md p-4 rounded-2xl bg-bg-surface border border-accent-violet/40 shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-violet/20 border border-accent-violet/40 flex items-center justify-center text-accent-violet font-bold text-sm shrink-0">
⚡
</div>
<div>
<h4 className="text-sm font-bold text-text-primary">Actualización disponible</h4>
<p className="text-xs text-text-secondary mt-0.5">
Se ha descargado una versión renovada en segundo plano. Actualice para disfrutar de las mejoras.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={updateApp}
className="px-3.5 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer shadow-md"
>
Actualizar ahora
</button>
</div>
</div>
)}
{/* 3. Banner para instalar la PWA */}
{installPrompt && !isStandalone && (
<div className="pointer-events-auto w-full max-w-sm p-4 rounded-2xl bg-bg-surface border border-border shadow-2xl space-y-3 animate-slide-up">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-violet/15 border border-accent-violet/30 flex items-center justify-center text-accent-violet font-bold text-base shrink-0">
AL
</div>
<div className="flex-1">
<h4 className="text-sm font-bold text-text-primary">Instalar Fullstack Agentic Lab</h4>
<p className="text-xs text-text-secondary mt-0.5">
Acceda directamente desde la pantalla de inicio con soporte de contenidos offline.
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setInstallPrompt(null)}
className="px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
Más tarde
</button>
<button
type="button"
onClick={handleInstallClick}
className="px-3 py-1.5 text-xs font-semibold bg-accent-violet text-white rounded-lg hover:bg-accent-violet/90 transition-colors cursor-pointer"
>
Instalar
</button>
</div>
</div>
)}
</div>
);
}Si el servidor web (Nginx, Cloudflare, Vercel) entrega sw.js con Cache-Control: public, max-age=31536000, el navegador leerá el script desde su caché HTTP interna y nunca detectará modificaciones en la lógica del worker.
Solución: Configure de manera innegociable en el endpoint /sw.js la cabecera Cache-Control: no-cache, no-store, must-revalidate.
Un manejador simple de fetch que guarde cualquier respuesta persistirá en disco los fallos transitorios del backend. Si el servidor devuelve un 500 durante unos segundos, el usuario mantendrá grabada la pantalla de error indefinidamente.
Solución: Valide siempre response.status === 200 antes de proceder al almacenamiento, o delegue este control en workbox-cacheable-response.
El contexto ServiceWorkerGlobalScope no dispone de acceso a window.localStorage ni a document. El uso de APIs de I/O síncronas colapsaría el bucle de procesamiento de red del navegador.
Solución: Emplee exclusivamente interfaces asíncronas como IndexedDB o Cache Storage dentro del hilo del Service Worker.
En escenarios de cobertura deteriorada ("Lie-Fi"), una llamada fetch() puede quedar retenida de 60 a 90 segundos antes de fallar. La interfaz queda congelada con un spinner pese a tener datos plenamente válidos en la caché local.
Solución: Fije un límite estricto: networkTimeoutSeconds: 3. Si la red no responde en 3 segundos, entregue de inmediato la copia local.
Un Service Worker servido desde /static/js/sw.js obtiene por defecto el Scope restringido /static/js/. No podrá interceptar peticiones destinadas a la raíz / o a rutas de API /api/*.
Solución: Sirva siempre el archivo desde la raíz del sitio web (/sw.js) o añada la cabecera HTTP Service-Worker-Allowed: /.
self.skipWaiting() y self.clients.claim() hacen posible un reemplazo de versiones suave sin requerir el cierre de ventanas.Cache-First para estáticos inmutables, Stale-While-Revalidate para latencia cero con actualización de fondo, y Network-First para datos críticos frescos.useSyncExternalStore sincroniza navigator.onLine sin discordancias de hidratación en SSR, mientras que beforeinstallprompt sustenta flujos de instalación visualmente integrados.