La lección anterior dejó una debilidad señalada: Iván baja al almacén de serigrafía, donde no llega el wifi, y Nómada Tareas ni siquiera abre. El tablero está guardado en localStorage, pero da igual, porque el index.html, el CSS y los quince módulos ES viven en el servidor y sin red el navegador no tiene nada que cargar. Cruzar esa frontera exige algo que no habías visto: un script que se ejecuta fuera de la página, que sigue vivo cuando la pestaña está cerrada, y que se sitúa entre tu aplicación y la red para responder a las peticiones desde una caché propia. Ese script es el service worker, y es la pieza que convierte una web en una aplicación web progresiva: instalable en el dispositivo, capaz de arrancar sin conexión y de comportarse como una aplicación nativa. En esta lección entenderás su ciclo de vida, dominarás la Cache API y sus estrategias, harás que Nómada Tareas funcione offline con cola de cambios pendientes, escribirás el manifiesto, resolverás el problema de las actualizaciones y aprenderás a depurarlo sin volverte loco.

Contenido

  1. Qué es una PWA
  2. Los tres requisitos
  3. El service worker: un proxy que vive fuera de la página
  4. Registro y ámbito (scope)
  5. El ciclo de vida: install, waiting, activate, fetch
  6. Por qué no tiene DOM ni localStorage
  7. La Cache API
  8. Precachear el app shell en install
  9. Limpiar versiones antiguas en activate
  10. Las estrategias de red
  11. Interceptar con fetch: el enrutador del service worker
  12. Funcionar sin conexión: respaldo y cola de cambios
  13. El manifiesto y la instalación
  14. Actualizar sin dejar a nadie atrás
  15. Depurar en DevTools
  16. Notificaciones push, brevemente
  17. Nómada Tareas: sw.js completo
  18. Errores Comunes y Consejos
  19. Ejercicios
  20. Conclusión

  1. Qué es una PWA

Una aplicación web progresiva no es una tecnología: es un conjunto de capacidades que, sumadas, hacen que una web se comporte como una aplicación instalada. Sigue siendo HTML, CSS y JavaScript servidos por una URL.

Web normal PWA App nativa
Instalación No , desde el navegador Tienda de aplicaciones
Icono en el escritorio No
Funciona sin conexión No , si la programas
Ventana propia, sin barra del navegador No (display: standalone)
Notificaciones push Limitado
Actualización Instantánea, al recargar Instantánea (controlada por ti) Revisión de la tienda
Distribución Una URL Una URL Tienda, revisión, comisión
Acceso al hardware Limitado Limitado (mejorando) Completo
Tamaño de descarga Lo que pesa la web Lo que pesa la web Decenas de MB

El adjetivo progresiva es la clave y explica el enfoque: la aplicación funciona en cualquier navegador, y en los que soportan más capacidades añade funciones. Nadie se queda fuera. Es la misma mejora progresiva que aplicaste en 07-01 al degradar a un almacén en memoria si localStorage fallaba.

Para Taller Nómada la propuesta es concreta: Marta pone Nómada Tareas en la pantalla de la sala como aplicación instalada, e Iván la abre en el móvil en el almacén sin cobertura, ve el tablero, marca una tarea como hecha, y ese cambio se envía solo cuando vuelve a subir.

  1. Los tres requisitos

Requisito Qué es Por qué
HTTPS La página debe servirse por conexión segura Un service worker intercepta todas las peticiones; sobre HTTP, un atacante en la red podría inyectar uno malicioso y permanente
Manifiesto Un manifest.json enlazado desde el HTML Da nombre, iconos y modo de presentación a la aplicación instalada
Service worker Un script registrado con al menos un manejador fetch Es lo que permite funcionar sin conexión

Una excepción práctica: http://localhost está permitido para desarrollo. Es lo que hace posible probar todo esto en tu máquina sin certificados.

  1. El service worker: un proxy que vive fuera de la página

Esta es la idea central, y hay que entenderla antes de escribir código. Un service worker no es un script de tu página. Es un worker independiente que el navegador ejecuta en su propio hilo, con su propio contexto global, y que se sitúa entre todas las páginas de tu origen y la red.

flowchart LR
    subgraph Antes["Sin service worker"]
        P1["Página"] -->|fetch| R1["Red"]
    end

    subgraph Despues["Con service worker"]
        P2["Página"] -->|fetch| SW["Service Worker<br/>(hilo propio)"]
        SW -->|"¿está en caché?"| C[("Cache API")]
        SW -->|"si no, o según estrategia"| R2["Red"]
        C -.->|"respuesta"| SW
        R2 -.->|"respuesta"| SW
        SW -.->|"respuesta"| P2
    end

Cinco propiedades que definen lo que es y lo que no:

  • Vive fuera de la página y sobrevive a que se cierre la pestaña. El navegador lo arranca cuando lo necesita y lo detiene cuando no.
  • Intercepta todas las peticiones de las páginas de su ámbito: HTML, CSS, JS, imágenes, llamadas a la API. Todas pasan por su evento fetch.
  • No tiene acceso al DOM. No existe document, ni window, ni tus elementos.
  • Es asíncrono por completo. Nada de APIs síncronas: por eso no puede usar localStorage.
  • Puede detenerse en cualquier momento. No guardes estado en variables globales del worker: al arrancar de nuevo, se habrán perdido.

La consecuencia de la segunda propiedad es la que exige respeto: un service worker mal escrito puede romper tu sitio para todos los visitantes, y como se guarda en el dispositivo, sigue roto aunque arregles el servidor. Es una herramienta potente y persistente. De ahí la exigencia de HTTPS.

  1. Registro y ámbito (scope)

El registro se hace desde la página, con un fetch implícito al fichero del worker:

// js/app.js — al final, cuando lo esencial ya funciona
if ('serviceWorker' in navigator) {                    // ← mejora progresiva
  window.addEventListener('load', async () => {        // no compitas con la carga inicial
    try {
      const registro = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
      console.log('[nomada] Service worker registrado. Ámbito:', registro.scope);
    } catch (error) {
      console.error('[nomada] Fallo al registrar el service worker:', error);
    }
  });
}

El ámbito decide qué páginas controla, y sigue una regla estricta: un service worker solo puede controlar URLs que estén en su carpeta o por debajo.

Ubicación del fichero Ámbito por defecto Controla
/sw.js / Todo el sitio
/js/sw.js /js/ Solo /js/… — casi nunca lo que quieres
/app/sw.js /app/ Solo /app/…

Por eso sw.js va en la raíz del sitio, no en js/ junto a los demás módulos. Es la excepción a la organización de carpetas del proyecto, y la causa número uno de «lo registré y no intercepta nada».

nomada-tareas/
  index.html
  manifest.json        ← nuevo
  sw.js                ← nuevo, EN LA RAÍZ (ámbito '/')
  offline.html         ← nuevo, página de respaldo
  css/estilos.css
  js/app.js
  js/…

Un servidor puede ampliar el ámbito de un worker que esté en un subdirectorio mediante la cabecera Service-Worker-Allowed, pero es una complicación innecesaria: pon el fichero en la raíz.

Y una advertencia de manual: register() devuelve una promesa que se resuelve cuando el registro se ha aceptado, no cuando el worker está activo y controlando la página. La primera visita de un usuario no está controlada por el service worker salvo que lo fuerces (apartado 14). Eso confunde muchísimo al probar.

  1. El ciclo de vida: install, waiting, activate, fetch

El ciclo de vida es lo que más cuesta de los service workers, y entenderlo evita el 80 % de los problemas.

stateDiagram-v2
    [*] --> Descargado: register() · el navegador descarga sw.js
    Descargado --> Instalando: evento install
    Instalando --> Instalado: waitUntil() resuelto (precaché lista)
    Instalando --> Fallido: waitUntil() rechazado
    Instalado --> Esperando: ya hay OTRO sw controlando páginas
    Instalado --> Activando: no hay ninguno (primera vez)
    Esperando --> Activando: se cierran todas las pestañas<br/>o skipWaiting()
    Activando --> Activo: evento activate terminado
    Activo --> Activo: evento fetch (por cada petición)
    Fallido --> [*]
Fase Evento Qué hacer en ella
Instalación install Precachear el app shell. Se ejecuta una sola vez por versión del worker
Espera El worker nuevo espera a que el viejo deje de controlar páginas
Activación activate Limpiar cachés de versiones antiguas. Momento seguro: ya no hay otro worker
Funcionamiento fetch Interceptar y responder a cada petición

La fase de espera es la que desconcierta. Si hay un service worker activo controlando pestañas abiertas, el nuevo se queda en waiting y no toma el control, aunque recargues con F5. Solo entra cuando todas las pestañas del sitio se cierran. Es una protección deliberada: evita que a mitad de sesión cambien las reglas bajo los pies del usuario, con una página vieja pidiendo recursos que la caché nueva ya borró.

El navegador decide que un worker es "nuevo" comparando el fichero byte a byte con el que tiene guardado. Un solo carácter distinto —típicamente el número de versión de la caché— basta para desencadenar el ciclo completo.

Y una pieza esencial: event.waitUntil(). Como el navegador puede detener el worker en cuanto el manejador retorna, hay que decirle explícitamente que espere a una promesa.

self.addEventListener('install', (evento) => {
  evento.waitUntil(                                  // ← sin esto, el worker puede morir a mitad
    caches.open('nomada-v1').then((cache) => cache.addAll(RECURSOS))
  );
});

Sin waitUntil, la instalación se daría por terminada antes de que la caché se llenara, y tendrías un service worker "instalado" con una caché a medias.

  1. Por qué no tiene DOM ni localStorage

Dentro de sw.js, el objeto global no es window, es self (un ServiceWorkerGlobalScope). Lo que hay y lo que no:

Disponible No disponible
fetch, caches, indexedDB document, window, DOM
postMessage, clients localStorage y sessionStorage
setTimeout, Promise, async/await alert, confirm, prompt
importScripts() y módulos ES (con type: 'module') Acceso directo a la interfaz

Las dos ausencias importantes tienen motivos distintos:

  • No hay DOM porque el worker no pertenece a ninguna página: puede haber cero, una o cinco pestañas abiertas, o ninguna. Para hablar con las páginas se usa postMessage, y para cambiar la interfaz, la página escucha y actúa.
  • No hay localStorage porque es síncrono, y en un worker que atiende peticiones de red eso sería un desastre de rendimiento. Es la misma advertencia de 07-01 llevada a su conclusión: las alternativas son la Cache API (para respuestas HTTP) e IndexedDB (para datos).

Esto tiene una consecuencia práctica para Nómada Tareas: la copia del tablero que guardaste en localStorage no es accesible desde el service worker. Si quieres que el worker maneje una cola de cambios pendientes, esa cola tiene que vivir en IndexedDB.

Comunicación entre página y worker, en ambos sentidos:

// Desde la página, hacia el worker
navigator.serviceWorker.controller?.postMessage({ tipo: 'limpiar-cache-api' });

// Desde la página, escuchando al worker
navigator.serviceWorker.addEventListener('message', (evento) => {
  if (evento.data.tipo === 'sincronizado') {
    vista.avisar(`${evento.data.cuantos} cambios enviados al servidor.`);
  }
});
// Dentro de sw.js
self.addEventListener('message', (evento) => {
  if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});

/** Avisa a TODAS las pestañas controladas. */
async function avisarAClientes(mensaje) {
  const clientes = await self.clients.matchAll({ includeUncontrolled: true });
  for (const cliente of clientes) cliente.postMessage(mensaje);
}

  1. La Cache API

caches es un almacén de pares petición/respuesta HTTP. No guarda datos como localStorage: guarda objetos Response completos, con sus cabeceras y su estado.

// Abrir (o crear) una caché con nombre
const cache = await caches.open('nomada-shell-v1');

// Guardar: descarga y almacena
await cache.add('/css/estilos.css');
await cache.addAll(['/index.html', '/js/app.js', '/css/estilos.css']);

// Guardar una respuesta que ya tienes
await cache.put('/api/tareas', respuesta.clone());     // ← clone(), el cuerpo se lee una vez (07-02)

// Buscar
const guardada = await cache.match('/css/estilos.css');
const enCualquiera = await caches.match('/css/estilos.css');   // busca en TODAS las cachés

// Gestionar
await cache.delete('/js/viejo.js');
const nombres = await caches.keys();                   // ['nomada-shell-v1', 'nomada-datos-v1']
await caches.delete('nomada-shell-v0');
Método Sobre Qué hace
caches.open(nombre) caches Abre o crea una caché con nombre
caches.match(peticion) caches Busca en todas las cachés
caches.keys() caches Lista los nombres
caches.delete(nombre) caches Borra una caché entera
cache.add(url) una caché Descarga y guarda
cache.addAll([urls]) una caché Igual, en lote. Si una falla, fallan todas
cache.put(peticion, respuesta) una caché Guarda una respuesta que ya tienes
cache.match(peticion) una caché Busca en esa caché
cache.delete(peticion) una caché Borra una entrada

Cuatro detalles que se aprenden a base de tropezar:

  • addAll es atómico. Si una sola URL da 404, la promesa rechaza y no se guarda ninguna. Es útil (una precaché a medias es peor que ninguna) y desconcertante: una ruta mal escrita rompe toda la instalación.
  • put no comprueba el estado. Guardará encantada una respuesta 404 o 500. Comprueba respuesta.ok antes.
  • El cuerpo se consume al leerlo, igual que en 07-02. Si vas a devolver la respuesta y guardarla, clona.
  • La caché es del origen, con la misma frontera que localStorage, y comparte cuota con IndexedDB. Cachear vídeos la llena rápido.

  1. Precachear el app shell en install

El app shell es el conjunto mínimo de recursos que la aplicación necesita para pintar su estructura: el HTML, el CSS, los módulos JavaScript, la fuente, los iconos. Los datos no forman parte del shell; se piden aparte.

// sw.js
const VERSION = 'v3';                                   // ← súbela en cada despliegue
const CACHE_SHELL = `nomada-shell-${VERSION}`;

const RECURSOS_SHELL = [
  '/',                          // importante: la raíz, además del index.html
  '/index.html',
  '/offline.html',
  '/manifest.json',
  '/css/estilos.css',
  '/js/app.js',
  '/js/modelo/tarea.js',
  '/js/modelo/tablero.js',
  '/js/modelo/errores.js',
  '/js/datos/backlog.js',
  '/js/datos/repositorio-local.js',
  '/js/datos/api-tareas.js',
  '/js/datos/http.js',
  '/js/datos/tiempo-real.js',
  '/js/util/fechas.js',
  '/js/util/formato.js',
  '/js/util/tiempo.js',
  '/js/vista/dom.js',
  '/js/vista/tarjeta.js',
  '/js/vista/pintar.js',
  '/js/vista/tablero-vista.js',
  '/js/vista/eventos.js',
  '/js/vista/controlador.js',
  '/js/vista/formulario.js',
  '/iconos/icono-192.png',
  '/iconos/icono-512.png'
];

self.addEventListener('install', (evento) => {
  console.log(`[sw] Instalando ${VERSION}`);
  evento.waitUntil(
    caches.open(CACHE_SHELL)
      .then((cache) => cache.addAll(RECURSOS_SHELL))
      .then(() => console.log('[sw] App shell precacheado'))
  );
});

Aquí aparece el precio de los módulos ES de 05-04: cada fichero es una petición, y todos tienen que estar en la lista. Si olvidas uno, la aplicación arrancará sin conexión hasta el import que falta y ahí morirá. Dos consejos: manténla ordenada por carpetas para poder auditarla de un vistazo, y recuerda que en un proyecto con empaquetador esta lista se genera automáticamente (es uno de los motivos por los que existen, y lo verás en 09-05).

Fíjate también en que '/' y '/index.html' son dos entradas distintas para la caché, aunque el servidor devuelva lo mismo. Si solo cacheas una, la otra fallará offline.

  1. Limpiar versiones antiguas en activate

Cada versión crea su propia caché. Sin limpieza, el dispositivo acumularía nomada-shell-v1, v2, v3… hasta agotar la cuota. El activate es el momento seguro para borrar: el worker viejo ya no controla nada.

self.addEventListener('activate', (evento) => {
  console.log(`[sw] Activando ${VERSION}`);
  evento.waitUntil((async () => {
    const nombres = await caches.keys();

    await Promise.all(
      nombres
        .filter((nombre) => nombre.startsWith('nomada-') && !nombre.endsWith(VERSION))
        .map((nombre) => {
          console.log('[sw] Borrando caché antigua:', nombre);
          return caches.delete(nombre);
        })
    );

    await self.clients.claim();      // toma el control de las pestañas ya abiertas
  })());
});

Dos puntos:

  • Filtra por prefijo nomada-. El origen puede tener otras cachés (de otra aplicación del mismo dominio, o de una librería). Borrar todo lo que haya sería el equivalente al localStorage.clear() que desaconsejamos en 07-01.
  • clients.claim() hace que el worker recién activado tome el control de las pestañas que ya estaban abiertas sin recargarlas. Sin él, seguirían sin controlador hasta la siguiente navegación.

  1. Las estrategias de red

Aquí está el verdadero diseño. Interceptar peticiones no sirve de nada si no decides qué hacer con cada una, y no todos los recursos merecen el mismo trato.

Estrategia Cómo funciona Ventaja Inconveniente En Nómada Tareas
Cache first Mira la caché; si no está, red Instantáneo, funciona offline Puede servir contenido viejo CSS, JS, fuentes, iconos, imágenes
Network first Prueba la red; si falla, caché Siempre lo más fresco Lento si la red va mal La API de tareas
Stale-while-revalidate Devuelve la caché ya y actualiza en segundo plano Rápido y se mantiene fresco La primera vez muestra lo viejo Avatares, catálogos, listas de etiquetas
Network only Siempre red, sin caché Nunca datos obsoletos No funciona offline POST/PATCH/DELETE, WebSocket
Cache only Solo caché Predecible Falla si no está precacheado Recursos del shell versionados

Las tres primeras, en código:

/** Cache first: para lo que no cambia dentro de una misma versión. */
async function cacheFirst(peticion) {
  const guardada = await caches.match(peticion);
  if (guardada) return guardada;

  const respuesta = await fetch(peticion);
  if (respuesta.ok) {
    const cache = await caches.open(CACHE_SHELL);
    cache.put(peticion, respuesta.clone());       // clone: el original se devuelve
  }
  return respuesta;
}

/** Network first: para datos, con la caché como red de seguridad. */
async function networkFirst(peticion, nombreCache = CACHE_DATOS) {
  try {
    const respuesta = await fetch(peticion);
    if (respuesta.ok) {
      const cache = await caches.open(nombreCache);
      cache.put(peticion, respuesta.clone());
    }
    return respuesta;
  } catch {
    const guardada = await caches.match(peticion);
    if (guardada) {
      // Marcamos la respuesta para que la interfaz pueda avisar de que es antigua
      const cabeceras = new Headers(guardada.headers);
      cabeceras.set('X-Desde-Cache', 'true');
      return new Response(guardada.body, { status: 200, headers: cabeceras });
    }
    throw new Error('Sin red y sin copia en caché');
  }
}

/** Stale-while-revalidate: lo mejor de los dos mundos para datos poco críticos. */
async function staleWhileRevalidate(peticion, nombreCache = CACHE_DATOS) {
  const cache = await caches.open(nombreCache);
  const guardada = await cache.match(peticion);

  const actualizando = fetch(peticion)
    .then((respuesta) => {
      if (respuesta.ok) cache.put(peticion, respuesta.clone());
      return respuesta;
    })
    .catch(() => null);                            // sin red: no pasa nada, ya devolvimos la caché

  return guardada ?? await actualizando;           // ← devuelve YA lo guardado si lo hay
}

Ese X-Desde-Cache merece un comentario: es una cabecera inventada por nosotros que permite a la aplicación distinguir un dato fresco de uno recuperado del pasado. Sin ella, el usuario vería el tablero de ayer creyendo que es el de hoy, y eso es exactamente el tipo de mentira que hay que evitar.

  1. Interceptar con fetch: el enrutador del service worker

El manejador fetch recibe todas las peticiones. Aplicar una sola estrategia a todas sería un error; lo que se escribe es un enrutador.

self.addEventListener('fetch', (evento) => {
  const peticion = evento.request;
  const url = new URL(peticion.url);

  // 1 · Solo GET: nunca interceptes escrituras
  if (peticion.method !== 'GET') return;                    // sin respondWith: va a la red normal

  // 2 · Solo nuestro origen (y lo que decidamos permitir)
  if (url.origin !== self.location.origin && !esApiPermitida(url)) return;

  // 3 · Navegaciones (el usuario abre o recarga la página)
  if (peticion.mode === 'navigate') {
    evento.respondWith(manejarNavegacion(peticion));
    return;
  }

  // 4 · Llamadas a la API: network first
  if (url.pathname.startsWith('/api/') || esApiPermitida(url)) {
    evento.respondWith(networkFirst(peticion));
    return;
  }

  // 5 · Todo lo demás (CSS, JS, imágenes): cache first
  evento.respondWith(cacheFirst(peticion));
});

Tres reglas de oro del manejador fetch:

  • evento.respondWith() debe llamarse de forma síncrona. No puedes hacer un await antes de decidir si respondes; primero llamas a respondWith con una promesa, y esa promesa hace el trabajo. Si el manejador termina sin llamarlo, la petición sigue su curso normal, que es exactamente lo que quieres para lo que no gestionas.
  • Nunca interceptes peticiones que no sean GET. Un POST cacheado o duplicado crea datos fantasma. Y en 07-03 ya aprendiste lo caro que sale repetir un POST.
  • Devuelve siempre algo. Si la promesa de respondWith rechaza, el navegador muestra un error de red genérico. Es preferible responder con una página o un JSON de respaldo.

  1. Funcionar sin conexión: respaldo y cola de cambios

Con lo anterior, la aplicación arranca sin conexión. Falta cerrar dos huecos.

Página de respaldo para las navegaciones a rutas que no están cacheadas:

async function manejarNavegacion(peticion) {
  try {
    return await fetch(peticion);                    // network first: siempre el HTML más fresco
  } catch {
    const guardada = await caches.match('/index.html');
    return guardada ?? await caches.match('/offline.html');
  }
}
<!-- offline.html — autónomo: no puede depender de nada que no esté cacheado -->
<main class="offline">
  <h1>Sin conexión</h1>
  <p>Nómada Tareas no puede contactar con el servidor ahora mismo.</p>
  <p>Tus cambios se están guardando en el dispositivo y se enviarán en cuanto vuelva la conexión.</p>
  <button type="button" onclick="location.reload()">Reintentar</button>
</main>

Detectar el estado de la conexión desde la página:

// js/app.js
function actualizarConectividad() {
  const enLinea = navigator.onLine;
  $('#estado-red').textContent = enLinea ? '' : 'Sin conexión — trabajando en local';
  $('#estado-red').hidden = enLinea;
  document.body.classList.toggle('sin-conexion', !enLinea);
}

window.addEventListener('online',  () => { actualizarConectividad(); sincronizarPendientes(); });
window.addEventListener('offline', actualizarConectividad);
actualizarConectividad();

Una advertencia importante sobre navigator.onLine: solo dice si hay una interfaz de red activa, no si hay Internet de verdad. Un wifi conectado a un router sin salida da true. Es una pista útil, nunca una garantía; la única prueba real es intentar la petición, con el manejo de errores de 07-03.

Cola de cambios pendientes. Como el service worker no puede usar localStorage, la cola vive en IndexedDB, que sí comparte con la página:

// js/datos/cola-pendientes.js (en la PÁGINA, no en el worker)
const BD = 'nomada-pendientes';
const ALMACEN = 'cambios';

function abrir() {
  return new Promise((resolver, rechazar) => {
    const solicitud = indexedDB.open(BD, 1);
    solicitud.onupgradeneeded = () => {
      solicitud.result.createObjectStore(ALMACEN, { keyPath: 'id', autoIncrement: true });
    };
    solicitud.onsuccess = () => resolver(solicitud.result);
    solicitud.onerror = () => rechazar(solicitud.error);
  });
}

export async function encolar(cambio) {
  const bd = await abrir();
  const tx = bd.transaction(ALMACEN, 'readwrite');
  tx.objectStore(ALMACEN).add({ ...cambio, ts: Date.now() });
  return new Promise((r) => { tx.oncomplete = r; });
}

export async function leerTodos() {
  const bd = await abrir();
  return new Promise((resolver) => {
    const solicitud = bd.transaction(ALMACEN).objectStore(ALMACEN).getAll();
    solicitud.onsuccess = () => resolver(solicitud.result);
  });
}

export async function eliminar(id) {
  const bd = await abrir();
  bd.transaction(ALMACEN, 'readwrite').objectStore(ALMACEN).delete(id);
}
// js/app.js — envía lo acumulado cuando vuelve la red
export async function sincronizarPendientes() {
  const pendientes = await leerTodos();
  if (pendientes.length === 0) return;

  vista.avisar(`Enviando ${pendientes.length} cambios pendientes…`);

  for (const cambio of pendientes) {
    try {
      await api.actualizarTarea(cambio.id, cambio.datos);   // con reintentos de 07-03
      await eliminar(cambio.claveCola);
    } catch (error) {
      if (!error.reintentable) await eliminar(cambio.claveCola);   // no insistas con un 400
      break;                                                       // el resto, en el próximo intento
    }
  }
  vista.avisar('Sincronización completada.');
}

Existe además la Background Sync API, que permite registrar una sincronización que el navegador ejecutará cuando haya conexión, aunque la pestaña esté cerrada:

// Desde la página
const registro = await navigator.serviceWorker.ready;
if ('sync' in registro) await registro.sync.register('enviar-cambios');
// Dentro de sw.js
self.addEventListener('sync', (evento) => {
  if (evento.tag === 'enviar-cambios') evento.waitUntil(enviarCambiosPendientes());
});

Es elegante, pero su soporte no es universal, así que trátala como una mejora sobre la sincronización con el evento online, nunca como el mecanismo principal.

  1. El manifiesto y la instalación

El manifiesto es un JSON que describe la aplicación instalada:

{
  "name": "Nómada Tareas — Taller Nómada",
  "short_name": "Nómada",
  "description": "Gestión de tareas del taller y el coworking",
  "start_url": "/?origen=pwa",
  "scope": "/",
  "display": "standalone",
  "orientation": "any",
  "background_color": "#faf7f2",
  "theme_color": "#2b6b5b",
  "lang": "es",
  "dir": "ltr",
  "icons": [
    { "src": "/iconos/icono-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/iconos/icono-512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/iconos/icono-mascara.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "shortcuts": [
    { "name": "Nueva tarea", "url": "/?accion=nueva", "description": "Crear una tarea" }
  ]
}
<!-- index.html, dentro de <head> -->
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2b6b5b">
<link rel="apple-touch-icon" href="/iconos/icono-192.png">
Campo Para qué Nota
name Nombre completo, en la pantalla de instalación
short_name Bajo el icono Máximo ~12 caracteres o se corta
start_url Qué se abre al pulsar el icono El parámetro permite medir cuánta gente la usa instalada
scope Qué URLs pertenecen a la aplicación Fuera del scope se abre el navegador
display standalone, fullscreen, minimal-ui, browser standalone es lo habitual: sin barra de direcciones
theme_color Color de la barra del sistema
background_color Fondo de la pantalla de arranque Ponlo igual que el fondo real, o se verá un destello
icons Iconos 192 y 512 px como mínimo; añade uno maskable

El icono maskable es un detalle que se nota: Android recorta los iconos en formas distintas (círculo, cuadrado redondeado), y sin él tu logotipo puede quedar decapitado. Un maskable deja margen de seguridad alrededor.

El aviso de instalación. Cuando se cumplen los requisitos, el navegador dispara beforeinstallprompt, y puedes controlar el momento:

let promesaInstalacion = null;

window.addEventListener('beforeinstallprompt', (evento) => {
  evento.preventDefault();                       // ← evita el aviso automático del navegador
  promesaInstalacion = evento;
  $('#instalar').hidden = false;                 // muestra TU botón, en TU momento
});

$('#instalar').addEventListener('click', async () => {
  if (promesaInstalacion === null) return;

  promesaInstalacion.prompt();
  const { outcome } = await promesaInstalacion.userChoice;
  console.log('[nomada] Instalación:', outcome);       // 'accepted' | 'dismissed'

  promesaInstalacion = null;                     // el evento es de un solo uso
  $('#instalar').hidden = true;
});

window.addEventListener('appinstalled', () => {
  $('#instalar').hidden = true;
  console.log('[nomada] Instalada');
});

Dos normas de buena educación, hermanas de las que verás en 07-06 con los permisos: no pidas instalar nada más entrar —el usuario no sabe todavía si tu aplicación le interesa—, y ofrécelo cuando haya demostrado interés, por ejemplo tras crear su tercera tarea. Y si lo rechaza, no vuelvas a preguntar en semanas.

  1. Actualizar sin dejar a nadie atrás

Este es el problema práctico más molesto de las PWAs: el usuario puede quedarse con una versión antigua indefinidamente. Con una web normal, un F5 trae lo último; con una PWA mal configurada, el service worker sirve la caché vieja para siempre.

El navegador comprueba si sw.js cambió al navegar (y como mucho cada 24 h). Si cambió, instala el nuevo… que se queda esperando. Hay tres estrategias:

Estrategia Cómo Ventaja Inconveniente
Esperar (por defecto) El nuevo entra cuando se cierran todas las pestañas Nunca rompe una sesión en curso El usuario que nunca cierra la pestaña no actualiza jamás
skipWaiting() inmediato El nuevo toma el control ya Actualización garantizada Peligroso: la página en curso puede pedir recursos ya borrados
Avisar y dejar decidir «Hay una versión nueva. Actualizar» Seguro y transparente Un poco más de código

La tercera es la correcta, y así se implementa:

// sw.js
self.addEventListener('install', (evento) => {
  evento.waitUntil(caches.open(CACHE_SHELL).then((c) => c.addAll(RECURSOS_SHELL)));
  // ← NO llamamos a skipWaiting() aquí: esperamos a que el usuario acepte
});

self.addEventListener('message', (evento) => {
  if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});
// js/app.js
const registro = await navigator.serviceWorker.register('/sw.js');

// 1 · Detectar que hay un worker nuevo esperando
registro.addEventListener('updatefound', () => {
  const nuevo = registro.installing;

  nuevo.addEventListener('statechange', () => {
    // 'installed' + hay un controlador = es una ACTUALIZACIÓN, no la primera instalación
    if (nuevo.state === 'installed' && navigator.serviceWorker.controller) {
      mostrarAvisoDeActualizacion(nuevo);
    }
  });
});

function mostrarAvisoDeActualizacion(trabajadorNuevo) {
  $('#aviso-version').hidden = false;
  $('#aviso-version-actualizar').addEventListener('click', () => {
    trabajadorNuevo.postMessage({ tipo: 'saltar-espera' });    // 2 · el usuario acepta
  }, { once: true });
}

// 3 · Cuando el nuevo toma el control, recargar UNA vez
let recargando = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (recargando) return;                                       // ← guardia contra bucles
  recargando = true;
  window.location.reload();
});

// Comprobar si hay actualizaciones al volver a la pestaña
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') registro.update();
});

Esa bandera recargando no es opcional: sin ella, controllerchange puede dispararse más de una vez y provocar un bucle infinito de recargas, uno de los fallos más desagradables que puede sufrir un usuario.

Y una regla de despliegue que evita el peor escenario: el fichero sw.js nunca debe cachearse en el servidor. Configúralo con Cache-Control: no-cache. Si un CDN sirve un sw.js viejo durante horas, tus usuarios se quedan congelados en una versión antigua y no hay nada que puedas hacer desde el cliente.

  1. Depurar en DevTools

En DevTools → Application tienes el panel de control completo:

Sección Para qué
Service Workers Ver el estado (installing / waiting / activated), forzar skipWaiting, Unregister, ver los registros
Manifest Comprobar que el manifiesto se lee bien y ver los iconos detectados
Cache Storage Inspeccionar cada caché entrada por entrada, y borrarlas
StorageClear site data El botón nuclear: borra todo y desregistra el worker

Tres casillas que te salvarán la vida durante el desarrollo:

  • Update on reload: fuerza que el service worker nuevo se instale y active en cada recarga, saltándose la espera. Actívala mientras desarrollas. Es la diferencia entre iterar en segundos o pelear con cachés.
  • Bypass for network: ignora el service worker por completo, como si no existiera.
  • Offline (en Network): la única forma de probar de verdad que tu aplicación funciona sin conexión.

La trampa de la caché durante el desarrollo merece un párrafo propio porque le pasa a todo el mundo: cambias el CSS, recargas y no ves el cambio. La causa es tu propia estrategia cache-first sirviendo el fichero viejo. Los remedios, por orden:

  1. Marca Update on reload y Disable cache en DevTools.
  2. Sube la constante VERSION en sw.js en cada cambio de recursos.
  3. Como último recurso, Clear site data y recarga.

Y el consejo más valioso: registra el service worker solo en producción mientras desarrollas la aplicación, o detrás de una bandera. Depurar una aplicación con un proxy de caché entre medias multiplica el tiempo de cada iteración.

const ENTORNO_LOCAL = ['localhost', '127.0.0.1'].includes(location.hostname);
if ('serviceWorker' in navigator && (!ENTORNO_LOCAL || location.search.includes('sw=1'))) {
  navigator.serviceWorker.register('/sw.js');
}

  1. Notificaciones push, brevemente

Un service worker puede recibir mensajes del servidor aunque la aplicación esté cerrada, y mostrar una notificación del sistema. El mecanismo, en tres pasos:

// 1 · La página pide permiso y se suscribe (con la clave pública VAPID del servidor)
const registro = await navigator.serviceWorker.ready;
const suscripcion = await registro.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: CLAVE_PUBLICA_VAPID
});
await api.guardarSuscripcion(suscripcion);        // el servidor la necesita para enviarte cosas
// 2 · En sw.js: llega el push
self.addEventListener('push', (evento) => {
  const datos = evento.data?.json() ?? {};
  evento.waitUntil(self.registration.showNotification('Nómada Tareas', {
    body: datos.mensaje ?? 'Hay novedades en el tablero',
    icon: '/iconos/icono-192.png',
    data: { url: datos.url ?? '/' }
  }));
});

// 3 · El usuario pulsa la notificación
self.addEventListener('notificationclick', (evento) => {
  evento.notification.close();
  evento.waitUntil(self.clients.openWindow(evento.notification.data.url));
});

Tres cosas que hay que saber antes de plantearlo:

  • Requiere un servidor que gestione las suscripciones y firme los envíos con claves VAPID. No es solo cliente.
  • userVisibleOnly: true es obligatorio en la práctica: no puedes usar push para hacer trabajo silencioso.
  • Los permisos se estudian en 07-06, junto con la regla de oro: no pidas permiso de notificaciones nada más entrar. Es la forma más rápida de que te lo denieguen para siempre.

Para Nómada Tareas es probablemente innecesario: con el WebSocket de 07-04 el tablero ya se actualiza en vivo mientras está abierto, y las notificaciones aportan cuando la aplicación no lo está.

  1. Nómada Tareas: sw.js completo

// sw.js — en la RAÍZ del sitio, para que el ámbito sea '/'
const VERSION = 'v3';
const CACHE_SHELL = `nomada-shell-${VERSION}`;
const CACHE_DATOS = `nomada-datos-${VERSION}`;
const API = 'http://localhost:3000';                     // en producción, la URL real de la API

const RECURSOS_SHELL = [
  '/', '/index.html', '/offline.html', '/manifest.json',
  '/css/estilos.css',
  '/js/app.js',
  '/js/modelo/tarea.js', '/js/modelo/tablero.js', '/js/modelo/errores.js',
  '/js/datos/backlog.js', '/js/datos/repositorio-local.js',
  '/js/datos/api-tareas.js', '/js/datos/http.js', '/js/datos/tiempo-real.js',
  '/js/util/fechas.js', '/js/util/formato.js', '/js/util/tiempo.js',
  '/js/vista/dom.js', '/js/vista/tarjeta.js', '/js/vista/pintar.js',
  '/js/vista/tablero-vista.js', '/js/vista/eventos.js',
  '/js/vista/controlador.js', '/js/vista/formulario.js',
  '/iconos/icono-192.png', '/iconos/icono-512.png'
];

// ══════════════════ INSTALACIÓN ══════════════════
self.addEventListener('install', (evento) => {
  console.log(`[sw] install ${VERSION}`);
  evento.waitUntil(
    caches.open(CACHE_SHELL).then((cache) => cache.addAll(RECURSOS_SHELL))
  );
  // Sin skipWaiting(): la actualización la decide el usuario (apartado 14)
});

// ══════════════════ ACTIVACIÓN ══════════════════
self.addEventListener('activate', (evento) => {
  console.log(`[sw] activate ${VERSION}`);
  evento.waitUntil((async () => {
    const nombres = await caches.keys();
    await Promise.all(
      nombres
        .filter((n) => n.startsWith('nomada-') && !n.endsWith(VERSION))
        .map((n) => caches.delete(n))
    );
    await self.clients.claim();
  })());
});

// ══════════════════ MENSAJES DESDE LA PÁGINA ══════════════════
self.addEventListener('message', (evento) => {
  if (evento.data?.tipo === 'saltar-espera') self.skipWaiting();
});

// ══════════════════ ESTRATEGIAS ══════════════════
async function cacheFirst(peticion) {
  const guardada = await caches.match(peticion);
  if (guardada) return guardada;
  try {
    const respuesta = await fetch(peticion);
    if (respuesta.ok) (await caches.open(CACHE_SHELL)).put(peticion, respuesta.clone());
    return respuesta;
  } catch (error) {
    if (peticion.destination === 'image') return caches.match('/iconos/icono-192.png');
    throw error;
  }
}

async function networkFirst(peticion) {
  try {
    const respuesta = await fetch(peticion);
    if (respuesta.ok) (await caches.open(CACHE_DATOS)).put(peticion, respuesta.clone());
    return respuesta;
  } catch {
    const guardada = await caches.match(peticion);
    if (guardada) {
      const cabeceras = new Headers(guardada.headers);
      cabeceras.set('X-Desde-Cache', 'true');            // la interfaz avisará de que es antiguo
      return new Response(guardada.body, { status: 200, headers: cabeceras });
    }
    // Respuesta JSON de respaldo: mejor que un error de red opaco
    return new Response(
      JSON.stringify({ error: 'sin-conexion', mensaje: 'Sin conexión y sin copia local.' }),
      { status: 503, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

async function manejarNavegacion(peticion) {
  try {
    return await fetch(peticion);
  } catch {
    return (await caches.match('/index.html')) ?? (await caches.match('/offline.html'));
  }
}

// ══════════════════ INTERCEPCIÓN ══════════════════
self.addEventListener('fetch', (evento) => {
  const peticion = evento.request;
  const url = new URL(peticion.url);

  if (peticion.method !== 'GET') return;                 // nunca POST/PATCH/DELETE
  if (url.protocol.startsWith('ws')) return;             // el WebSocket de 07-04 va aparte

  if (peticion.mode === 'navigate') {
    evento.respondWith(manejarNavegacion(peticion));
    return;
  }
  if (url.origin === API) {
    evento.respondWith(networkFirst(peticion));
    return;
  }
  if (url.origin === self.location.origin) {
    evento.respondWith(cacheFirst(peticion));
  }
  // Cualquier otro origen: sin respondWith, va a la red tal cual
});

Comprueba que funciona con este guion, que es también el que deberías repetir en cada despliegue:

  1. Sirve el sitio (npx serve en la carpeta del proyecto) y ábrelo.
  2. DevTools → Application → Service Workers: debe aparecer activated and running.
  3. Cache Storage: debe estar nomada-shell-v3 con todos los recursos.
  4. Network → marca Offline.
  5. Pulsa F5. La aplicación arranca, con el tablero de localStorage (07-01).
  6. Marca una tarea como hecha: se encola.
  7. Desmarca Offline: el cambio se envía y el WebSocket se reconecta con el retroceso de 07-04.

Ese punto 5 es el momento en el que Nómada Tareas deja de ser una web y pasa a ser una aplicación.

Errores Comunes y Consejos

  • Poner sw.js en js/. Su ámbito sería /js/ y no controlaría nada. Va en la raíz.
  • Olvidar event.waitUntil(). El navegador puede detener el worker antes de que la precaché termine, dejándola a medias.
  • Un 404 en RECURSOS_SHELL. addAll es atómico: una URL mal escrita impide que se guarde nada.
  • Interceptar peticiones que no son GET. Datos duplicados o perdidos.
  • Llamar a skipWaiting() siempre. La página en curso puede quedarse pidiendo recursos que la caché nueva ya borró. Avisa y deja decidir.
  • No poner la guardia en controllerchange. Bucle infinito de recargas.
  • Cachear sw.js en el servidor. Tus usuarios se congelan en una versión vieja. Cache-Control: no-cache.
  • Olvidar borrar cachés viejas. Se acumulan versiones hasta agotar la cuota.
  • Guardar respuestas sin comprobar respuesta.ok. Un 500 cacheado se sirve como si fuera bueno.
  • Olvidar clone(). TypeError: body stream already read, el mismo de 07-02.
  • Creer que navigator.onLine === true significa que hay Internet. Solo dice que hay una interfaz de red.
  • Confundir la primera visita con estar controlado. El worker no controla la página en la que se registró salvo que uses clients.claim().
  • Probar sin activar Offline. Un modo offline que nunca se ha probado no funciona; es una ley.
  • Consejo: Update on reload mientras desarrollas. Ahorra horas.
  • Consejo: sube VERSION en cada despliegue. Es lo que dispara el ciclo de instalación y la limpieza.
  • Consejo: registra el worker solo en producción mientras construyes la aplicación.
  • Consejo: pasa Lighthouse (DevTools → Lighthouse → Progressive Web App). Te dice exactamente qué falta.
  • Consejo: no pidas instalar ni notificar nada más entrar. Espera a que el usuario demuestre interés.

Ejercicios

Ejercicio 1 — Caché de datos con caducidad. La estrategia networkFirst guarda respuestas de la API sin límite de antigüedad, y un tablero de hace tres días es peor que ninguno. Escribe cacheConCaducidad(peticion, maxEdadMs) que, al guardar, añada una cabecera X-Cacheado-En con Date.now(), y al recuperar de la caché compruebe esa marca: si la respuesta es más vieja que maxEdadMs, la borra y lanza en lugar de devolverla. Añade una función limpiarCaducados(nombreCache, maxEdadMs) que se ejecute en activate.

Ejercicio 2 — Aviso de versión nueva accesible. Escribe vigilarActualizaciones(registro, { alHaberVersion }) que detecte un service worker nuevo en estado installed (distinguiendo actualización de primera instalación), llame a alHaberVersion con una función aplicar(), compruebe actualizaciones al volver a la pestaña visible y cada 60 minutos, y gestione controllerchange con la guardia contra bucles. El aviso en el HTML debe usar role="status" y aria-live="polite", ofrecer «Actualizar ahora» y «Más tarde», y recordar el rechazo durante la sesión con sessionStorage.

Ejercicio 3 — Auditar la precaché. La lista RECURSOS_SHELL se desincroniza en cuanto alguien añade un módulo. Escribe un script de Node auditar-shell.js que lea todos los ficheros .js bajo js/, extraiga sus rutas, las compare con la lista declarada en sw.js y avise de los que faltan y de los que sobran, terminando con código de salida 1 si hay diferencias. No uses librerías externas: node:fs y expresiones regulares bastan.

Soluciones

Solución 1

const CABECERA_FECHA = 'X-Cacheado-En';

async function guardarConMarca(nombreCache, peticion, respuesta) {
  const cabeceras = new Headers(respuesta.headers);
  cabeceras.set(CABECERA_FECHA, String(Date.now()));

  // Hay que reconstruir la Response: sus cabeceras son inmutables
  const conMarca = new Response(await respuesta.clone().blob(), {
    status: respuesta.status,
    statusText: respuesta.statusText,
    headers: cabeceras
  });

  const cache = await caches.open(nombreCache);
  await cache.put(peticion, conMarca);
}

function estaCaducada(respuesta, maxEdadMs) {
  const marca = Number(respuesta.headers.get(CABECERA_FECHA));
  if (!Number.isFinite(marca)) return true;          // sin marca: la tratamos como caducada
  return Date.now() - marca > maxEdadMs;
}

async function cacheConCaducidad(peticion, maxEdadMs = 3600000) {
  try {
    const respuesta = await fetch(peticion);
    if (respuesta.ok) await guardarConMarca(CACHE_DATOS, peticion, respuesta);
    return respuesta;
  } catch (error) {
    const cache = await caches.open(CACHE_DATOS);
    const guardada = await cache.match(peticion);

    if (!guardada) throw error;
    if (estaCaducada(guardada, maxEdadMs)) {
      await cache.delete(peticion);
      throw new Error('Copia en caché demasiado antigua');
    }
    return guardada;
  }
}

async function limpiarCaducados(nombreCache, maxEdadMs) {
  const cache = await caches.open(nombreCache);
  const peticiones = await cache.keys();

  await Promise.all(peticiones.map(async (peticion) => {
    const respuesta = await cache.match(peticion);
    if (respuesta && estaCaducada(respuesta, maxEdadMs)) await cache.delete(peticion);
  }));
}

El detalle que hay que descubrir a la fuerza: las cabeceras de una Response son inmutables. No puedes hacer respuesta.headers.set(...); hay que construir una Response nueva con las cabeceras modificadas, y para eso primero se lee el cuerpo del clon.

Solución 2

const CLAVE_RECHAZO = 'nomada:actualizacion-rechazada';

export function vigilarActualizaciones(registro, { alHaberVersion }) {
  let recargando = false;

  function evaluar(trabajador) {
    if (trabajador.state !== 'installed') return;
    if (!navigator.serviceWorker.controller) return;        // primera instalación, no actualización
    if (sessionStorage.getItem(CLAVE_RECHAZO) === 'si') return;

    alHaberVersion(() => trabajador.postMessage({ tipo: 'saltar-espera' }));
  }

  // Un worker que ya estaba esperando cuando cargamos
  if (registro.waiting && navigator.serviceWorker.controller) evaluar(registro.waiting);

  registro.addEventListener('updatefound', () => {
    const nuevo = registro.installing;
    nuevo?.addEventListener('statechange', () => evaluar(nuevo));
  });

  navigator.serviceWorker.addEventListener('controllerchange', () => {
    if (recargando) return;                                 // ← guardia contra el bucle infinito
    recargando = true;
    window.location.reload();
  });

  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') registro.update().catch(() => {});
  });
  setInterval(() => registro.update().catch(() => {}), 3600000);
}
<div id="aviso-version" role="status" aria-live="polite" hidden>
  <p>Hay una versión nueva de Nómada Tareas.</p>
  <button type="button" id="version-actualizar">Actualizar ahora</button>
  <button type="button" id="version-luego">Más tarde</button>
</div>
vigilarActualizaciones(registro, {
  alHaberVersion(aplicar) {
    const aviso = document.querySelector('#aviso-version');
    aviso.hidden = false;
    document.querySelector('#version-actualizar')
      .addEventListener('click', aplicar, { once: true });
    document.querySelector('#version-luego').addEventListener('click', () => {
      sessionStorage.setItem(CLAVE_RECHAZO, 'si');          // no insistir en esta sesión
      aviso.hidden = true;
    }, { once: true });
  }
});

La comprobación registro.waiting inicial es la que se olvida siempre: si el usuario abre la aplicación y ya había un worker esperando de una visita anterior, el evento updatefound no volverá a dispararse y el aviso nunca aparecería.

Solución 3

// auditar-shell.js — ejecutar con: node auditar-shell.js
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';

const RAIZ = process.cwd();

/** Recorre un directorio recursivamente devolviendo las rutas de los .js */
function listarJs(directorio) {
  const salida = [];
  for (const entrada of readdirSync(directorio)) {
    const ruta = join(directorio, entrada);
    if (statSync(ruta).isDirectory()) salida.push(...listarJs(ruta));
    else if (entrada.endsWith('.js')) salida.push('/' + relative(RAIZ, ruta).replaceAll('\\', '/'));
  }
  return salida;
}

const enDisco = new Set(listarJs(join(RAIZ, 'js')));

// Extrae las cadenas entre comillas de la lista RECURSOS_SHELL
const sw = readFileSync(join(RAIZ, 'sw.js'), 'utf8');
const bloque = sw.match(/const RECURSOS_SHELL\s*=\s*\[([\s\S]*?)\]/)?.[1] ?? '';
const declarados = new Set([...bloque.matchAll(/'([^']+)'/g)].map((m) => m[1]));

const faltan = [...enDisco].filter((r) => !declarados.has(r));
const sobran = [...declarados].filter((r) => r.startsWith('/js/') && !enDisco.has(r));

if (faltan.length) console.error('❌ Faltan en RECURSOS_SHELL:\n  ' + faltan.join('\n  '));
if (sobran.length) console.error('❌ Declarados pero inexistentes:\n  ' + sobran.join('\n  '));

if (faltan.length === 0 && sobran.length === 0) {
  console.log(`✅ La precaché está al día (${enDisco.size} módulos).`);
  process.exit(0);
}
process.exit(1);

El código de salida 1 es lo que convierte este script en algo útil de verdad: se puede enganchar a un npm run build o a la integración continua, y el despliegue falla si alguien añadió un módulo y olvidó la precaché. Es también una buena ilustración de por qué existen los empaquetadores: generan esta lista solos, y lo verás en 09-05.

Conclusión

Nómada Tareas ya funciona sin conexión y puede instalarse en el dispositivo. Sabes qué es una PWA —no una tecnología, sino un conjunto de capacidades sobre HTML, CSS y JavaScript— y sus tres requisitos: HTTPS (con localhost como excepción para desarrollo), manifiesto y service worker. Y entiendes la idea que lo sostiene todo: el service worker es un proxy que vive fuera de la página, en su propio hilo, que sobrevive al cierre de la pestaña, intercepta todas las peticiones de su ámbito y puede detenerse en cualquier momento —de ahí que no se guarde estado en sus variables globales—.

Dominas su ciclo de vida: install para precachear el app shell, la fase de espera que protege a las pestañas abiertas y que explica por qué tu worker nuevo «no entra», activate para limpiar versiones antiguas, y fetch para atender cada petición; con event.waitUntil() como pieza obligatoria para que el navegador no detenga el worker a mitad. Sabes que el ámbito lo determina la ubicación del fichero, y que por eso sw.js va en la raíz. Sabes por qué no tiene DOM (no pertenece a ninguna página) ni localStorage (es síncrono), y que las alternativas son la Cache API e IndexedDB, con postMessage y clients para hablar con las pestañas.

Manejas la Cache API con sus trampas —addAll atómico, put que no mira el estado, el clone() obligatorio— y, sobre todo, sabes elegir estrategia por tipo de recurso: cache-first para el shell, network-first para la API con la caché como red de seguridad, stale-while-revalidate para lo poco crítico, network-only para las escrituras. El manejador fetch que has escrito es un enrutador, no una regla única, y respeta las tres normas: respondWith síncrono, nunca interceptar lo que no sea GET, y devolver siempre algo. Con eso, más la página de respaldo, la cola de cambios en IndexedDB y los eventos online/offline —recordando que navigator.onLine solo indica que hay interfaz de red, no Internet—, Nómada Tareas arranca en el almacén de serigrafía y envía sola lo pendiente al volver la cobertura.

Y tienes resueltos los dos problemas operativos que hunden a las PWAs mal hechas: el manifiesto con sus iconos de 192 y 512 píxeles, el maskable, el short_name corto y el beforeinstallprompt controlado para no pedir instalación nada más entrar; y la actualización, con el patrón de avisar y dejar decidir, la comprobación de registro.waiting que casi todo el mundo olvida, la guardia contra el bucle de recargas en controllerchange, y la regla de despliegue que evita el desastre: sw.js nunca se cachea en el servidor. Sumado a Update on reload, Bypass for network y Clear site data, ya sabes salir de la trampa de la caché durante el desarrollo en lugar de sufrirla.

Con esto, la aplicación tiene sus grandes capacidades cubiertas: recuerda (07-01), habla con un servidor (07-02), aguanta los fallos de la red (07-03), se sincroniza en vivo (07-04) y funciona sin conexión e instalada (07-05). Lo que queda son las piezas medianas que separan una aplicación correcta de una que da gusto usar: cargar tarjetas solo cuando aparecen en pantalla, reaccionar a cambios de tamaño, copiar el resumen del tablero al portapapeles con un clic, hacer que los filtros sean enlazables y sobrevivan a la recarga, respetar que Marta prefiera el modo oscuro o que Lucía haya pedido menos animación, animar sin tirones, y formatear fechas y horas en español de verdad en lugar de a mano —esa función fechaLegible con su array de meses escrito a mano pide a gritos una reescritura—. Todo eso son APIs del navegador que ya están ahí, esperando: APIs del Navegador Esenciales.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados