🎯 Meta: convertir tu web en una app instalable que funciona sin conexión: service workers, estrategias de caché, cola offline y notificaciones push. Una PWA bien hecha sustituye a la app nativa en la mayoría de casos de negocio (y sin pasar por las stores).
Requisitos: cap. 15 (fetch, Vite), ap. L (caché HTTP — no la confundas con esta). Herramienta:
vite-plugin-pwa(Workbox por debajo). Requiere HTTPS (en local,localhostcuenta como seguro).
N.1 · Qué es una PWA (y qué no)
Una Progressive Web App = tu web + tres piezas:
1. HTTPS → requisito de todo lo demás
2. Web App Manifest → nombre, iconos, color: la hace INSTALABLE
3. Service Worker → proxy programable: la hace OFFLINE-CAPAZ| Mito | Realidad |
|---|---|
| "PWA = web metida en un wrapper" | Es tu misma web; el navegador la promociona a app |
| "Offline = toda la app sin red" | Offline = degradarse con elegancia: shell + datos cacheados + cola de acciones |
| "Es solo para móvil" | Se instalan en escritorio también (Chrome/Edge) |
Cuándo hacer PWA: apps de uso recurrente (el panel de cocina del cap. 22, un TPV, un fichaje), campo con mala cobertura, quioscos. Cuándo no complicarse: webs de consulta esporádica — el 100% del valor con el 0% de la complejidad de sincronización.
N.2 · El manifest: instalable en 20 líneas
// public/manifest.webmanifest
{
"name": "Cantina — Pedidos",
"short_name": "Cantina",
"description": "Pide y sigue tu pedido en tiempo real",
"start_url": "/",
"display": "standalone", // sin barra del navegador: parece app nativa
"background_color": "#0d1117",
"theme_color": "#2563eb",
"icons": [
{ "src": "/iconos/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/iconos/512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/iconos/512-maskable.png", "sizes": "512x512", "type": "image/png",
"purpose": "maskable" } // Android lo recorta en círculo sin romperlo
]
}<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#2563eb">Con manifest + service worker + HTTPS, el navegador ofrece "Instalar app". Auditalo en Lighthouse (categoría PWA) y DevTools → Application → Manifest.
Manifest avanzado: atajos, compartir y protocolos
Cuatro campos que convierten una PWA instalada en algo indistinguible de una app nativa en el menú del sistema:
{
"shortcuts": [
{ "name": "Nuevo pedido", "url": "/pedidos/nuevo",
"icons": [{ "src": "/iconos/nuevo-96.png", "sizes": "96x96" }] }
],
"share_target": {
"action": "/compartir", "method": "GET",
"params": { "title": "titulo", "text": "texto", "url": "enlace" }
},
"protocol_handlers": [
{ "protocol": "web+cantina", "url": "/pedido?id=%s" }
],
"display_override": ["window-controls-overlay", "standalone"]
}shortcuts: mantén pulsado el icono de la app (móvil) o clic derecho (escritorio) → accesos directos a acciones concretas, como los shortcuts de una app nativa.share_target: tu PWA aparece en el menú "Compartir" del sistema operativo — otra app puede compartirte una URL/texto y tu PWA la recibe como una petición normal.protocol_handlers: tu PWA se registra como manejadora de un esquema de URL propio (web+cantina://...), útil para enlaces profundos entre sistemas (equivalente al deep linking nativo del apéndice U).
💡 Ninguno de los tres es imprescindible para que la PWA funcione — son mejora progresiva pura: el navegador que no los soporta simplemente los ignora. Añádelos cuando el manifest básico (N.2) y el offline (N.3-N.5) ya estén sólidos.
N.3 · El service worker: un proxy dentro del navegador
Un service worker es un script que se instala entre tu app y la red. Intercepta cada fetch y decide: ¿red, caché, o una mezcla?
App ──fetch──▶ Service Worker ──▶ ¿estrategia?
│ ├─ Cache First → caché; si no está, red
│ ├─ Network First → red; si falla, caché
│ └─ Stale-While-Revalidate → caché YA + refresca detrás
▼
Cache Storage (independiente de la caché HTTP del ap. L)La decisión clave es qué estrategia para qué recurso:
| Recurso | Estrategia | Por qué |
|---|---|---|
| App shell (JS/CSS con hash) | Precache (en la instalación) | Inmutables: la app arranca sin red |
| Imágenes de productos | Cache First (+ límite) | Cambiar tarde no duele; ahorran datos |
GET /api/carta | Stale-While-Revalidate | Rápido siempre, fresco casi siempre |
GET /api/pedidos/:id | Network First (+ timeout) | Mejor fresco; cacheado si no hay red |
POST/PUT/DELETE | NUNCA se cachean | Se encolan (N.5) |
N.4 · Todo junto con vite-plugin-pwa
npm install -D vite-plugin-pwa// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'prompt', // tú controlas cuándo se actualiza (ver abajo)
manifest: { /* el manifest de N.2 aquí */ },
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], // precache del shell
navigateFallback: '/index.html', // SPA offline (el try_files local)
runtimeCaching: [
{
urlPattern: /\/api\/carta/,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'api-carta', expiration: { maxAgeSeconds: 3600 } },
},
{
urlPattern: /\/api\/pedidos/,
handler: 'NetworkFirst',
options: { cacheName: 'api-pedidos', networkTimeoutSeconds: 3 },
},
{
urlPattern: /\.(png|webp|avif)$/,
handler: 'CacheFirst',
options: { cacheName: 'imagenes',
expiration: { maxEntries: 100, maxAgeSeconds: 30 * 86400 } },
},
],
},
}),
],
});El ciclo de vida y el banner "hay nueva versión"
Un SW nuevo queda en espera hasta que se cierran todas las pestañas — por eso "deployé y los usuarios siguen viendo lo viejo". Con registerType: 'prompt' lo gestionas tú:
// React — banner de actualización
import { useRegisterSW } from 'virtual:pwa-register/react';
function BannerActualizacion() {
const { needRefresh: [hayNueva], updateServiceWorker } = useRegisterSW();
if (!hayNueva) return null;
return (
<div role="alert" className="banner">
Hay una versión nueva.
<button onClick={() => updateServiceWorker(true)}>Actualizar</button>
</div>
);
}⚠️ El SW puede servir contenido zombi si lo configuras mal. Reglas de supervivencia: nunca hagas precache de
index.htmlcon caché infinita manual, deja que Workbox gestione las revisiones, y en DevTools → Application ten a mano "Unregister" + "Clear storage" mientras desarrollas. El modo "Update on reload" es tu amigo.
N.5 · Escrituras offline: la cola de acciones
Leer offline es caché; escribir offline es una cola. El patrón (el mismo de las colas del apéndice B, pero en el cliente):
Usuario pulsa "marcar pedido listo" sin red
1. UI optimista: se marca YA (cap. 17.5)
2. La acción se guarda en IndexedDB: { id, url, method, body, creadaEn }
3. Al volver la red (evento 'online' / Background Sync) → se reenvía EN ORDEN
4. Conflicto (409/422) → se resuelve o se avisa al usuario// cola-offline.ts (esquema mínimo con idb)
import { openDB } from 'idb';
const db = await openDB('cola', 1, {
upgrade: (d) => { d.createObjectStore('acciones', { keyPath: 'id' }); },
});
export async function ejecutar(url: string, init: RequestInit) {
const accion = { id: crypto.randomUUID(), url, init, creadaEn: Date.now() };
try {
return await fetch(url, { ...init, headers: { ...init.headers, 'Idempotency-Key': accion.id } });
} catch { // sin red → encolar
await db.add('acciones', accion);
return new Response(null, { status: 202 }); // "aceptado, pendiente"
}
}
window.addEventListener('online', async () => {
for (const a of await db.getAll('acciones')) {
try { await fetch(a.url, a.init); await db.delete('acciones', a.id); }
catch { break; } // sigue sin red: reintenta luego
}
});🧠 La
Idempotency-Keyes la mitad de la solución: el reenvío puede duplicarse (se envió pero la respuesta se perdió). El backend guarda las keys procesadas y responde lo mismo sin repetir el efecto (pagos del mundo real funcionan así). Sin idempotencia, tu cola offline duplica pedidos.💡 Workbox trae esto hecho:
workbox-background-sync(BackgroundSyncPlugin) encola los POST fallidos y reintenta incluso con la pestaña cerrada (donde Background Sync existe; el eventoonlinees el fallback universal).
IndexedDB también sirve para los datos: la carta cacheada como datos estructurados (consultables) en vez de respuestas HTTP. Para apps muy offline (inventarios, rutas de reparto), mira RxDB/PouchDB/PowerSync — sincronización bidireccional seria.
N.6 · Notificaciones push (con cabeza)
Push = el servidor despierta al SW aunque la web esté cerrada. Piezas: Push API (recibir)
- Notifications API (mostrar) + protocolo Web Push/VAPID (tu backend firma los envíos).
// Frontend: pedir permiso SOLO tras una acción con contexto (jamás al aterrizar)
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
await fetch('/api/push/suscribir', { method: 'POST', body: JSON.stringify(sub) });// Backend (Node): npm i web-push
webpush.setVapidDetails('mailto:admin@cantina.com', VAPID_PUBLIC, VAPID_PRIVATE);
await webpush.sendNotification(suscripcion, JSON.stringify({
titulo: 'Pedido listo 🛎', cuerpo: 'Tu pedido #17 te espera', url: '/pedido/17',
}));// En el service worker
self.addEventListener('push', (e) => {
const d = e.data.json();
e.waitUntil(self.registration.showNotification(d.titulo, { body: d.cuerpo, data: d }));
});
self.addEventListener('notificationclick', (e) => {
e.notification.close();
e.waitUntil(clients.openWindow(e.notification.data.url));
});⚠️ Funciona en iOS ≥ 16.4 solo si la PWA está instalada en la pantalla de inicio. Y el permiso denegado es casi irreversible: pídelo en el momento con valor ("¿Te avisamos cuando tu pedido esté listo?"), nunca en el primer segundo.
Periodic Background Sync y Badging API
Dos APIs más pequeñas que rematan la sensación de "app instalada de verdad", con soporte todavía parcial (Chromium; no Safari) — trátalas como mejora progresiva, nunca como base:
// Periodic Background Sync: refresca datos en segundo plano aunque la PWA esté cerrada
// (requiere que el navegador considere la PWA "de uso frecuente" — no lo decides tú)
const registro = await navigator.serviceWorker.ready;
await registro.periodicSync.register('refrescar-carta', { minInterval: 24 * 60 * 60 * 1000 });
// En el service worker
self.addEventListener('periodicsync', (e) => {
if (e.tag === 'refrescar-carta') e.waitUntil(refrescarCacheDeCarta());
});// Badging API: el numerito rojo sobre el icono de la app (pedidos pendientes de cocina)
if ('setAppBadge' in navigator) await navigator.setAppBadge(pedidosPendientes);
if (pedidosPendientes === 0) await navigator.clearAppBadge();🧠 Ambas son azúcar sobre lo ya construido: Periodic Sync reutiliza la misma lógica de
refrescarCacheDeCartaque ya tienes para el SWR de N.4; el badge solo refleja un número que ya calculas para la UI. Nunca dependas de que se ejecuten — son "si el navegador quiere y puede", no un cron garantizado (para eso, tu backend sigue siendo la fuente de verdad).
N.7 · Buenas prácticas PWA
- Empieza por el shell offline + una estrategia por tipo de recurso. Push y Background Sync después.
POSTnunca a la caché; siempre a la cola, con Idempotency-Key y backend idempotente.- Banner de actualización controlado por ti (
prompt), no recargas sorpresa. - La UI dice la verdad: indicador offline, acciones "pendientes de sincronizar" marcadas.
- Presupuesta la caché (maxEntries/maxAge): el almacenamiento del navegador se puede purgar; tu app debe sobrevivir a una caché vacía.
- Prueba offline de verdad: DevTools → Network → Offline es el mínimo; modo avión en un móvil real es el examen.
- Lighthouse PWA + "Application" panel en cada release.
✅ Ejercicio del apéndice
El panel de cocina del proyecto final (cap. 22), a prueba de wifi de bar:
1. Manifest completo + iconos (uno maskable). Instálala en tu móvil.
2. Precache del shell + SWR para /api/carta + NetworkFirst para /api/pedidos.
Con el modo avión: la app abre y muestra los últimos pedidos conocidos.
3. Banner de "versión nueva" con registerType: 'prompt'.
4. Cola offline: "marcar como listo" sin red se aplica optimista, se encola en
IndexedDB y se sincroniza al volver la conexión (verifica el orden).
5. Idempotency-Key end-to-end: implementa la tabla de keys en el backend y
demuestra con un test que reenviar la misma acción no duplica el efecto.
6. Push: "pedido nuevo" despierta la PWA de cocina instalada (VAPID + web-push).
7. Indicador de estado (online/offline/sincronizando) visible y accesible
(aria-live, ap. M).💡 Pistas
- Si tras cambiar el SW ves código viejo: Application → Service Workers → "Update on reload" + "Clear storage". El 90% del sufrimiento PWA en dev es esto.
- El test del punto 5: misma key dos veces → segunda respuesta idéntica, y el contador de pedidos en BD sube UNA vez.
- Para probar push en local:
web-push generate-vapid-keysy dispara desde un script Node contra tu suscripción real.
🧠 Autoevaluación
La HTTP la gestiona el navegador según cabeceras y no es programable. La del SW es un proxy con lógica tuya: eliges estrategia por recurso, respondes offline, encolas escrituras. Se complementan: assets inmutables se benefician de ambas.
SWR muestra lo cacheado al instante y refresca detrás: perfecto para datos que toleran minutos de desfase. El estado de un pedido cambia y DECIDE acciones del usuario: mejor NetworkFirst (fresco si hay red, cacheado solo como último recurso, e idealmente marcado como "posiblemente desactualizado").
El reenvío puede ejecutarse dos veces (respuesta perdida, reintento). Con la key, el backend detecta la repetición y devuelve el resultado original sin repetir el efecto — sin ella, la sincronización duplica pedidos/pagos.
El SW nuevo queda "waiting" hasta que se cierren todas las pestañas, y mientras tanto el viejo sirve el precache antiguo. La solución es el flujo de actualización controlado: banner "hay nueva versión" que llama a updateServiceWorker(true) (skipWaiting + reload).
Es el navegador quien decide si y cuándo se ejecuta (según uso frecuente de la app, batería, soporte del navegador — Safari no lo implementa), no un cron garantizado. Sirve como optimización sobre la estrategia de caché que ya tienes (SWR/Network First); la fuente de verdad y el refresco al abrir la app siguen siendo imprescindibles.
Volver al: README.md · Relacionado: L-despliegue-frontend.md, B-redis-cache-colas.md, 21-tiempo-real.md