wandres.dev
SERVICE WORKERS EN DEVTOOLS · Depurar el SW

Inspeccionar y purgar la caché del worker

Ver qué está sirviendo el worker y desde dónde, distinguir en el panel de red una respuesta suya de una de la red, y las tres formas de purgar con sus consecuencias.

⏱ 17 min

Cuando un service worker interviene, el panel de red deja de contar la verdad completa: una petición que aparece resuelta puede no haber salido a la red, y una que aparece dos veces puede ser la misma vista desde dos ángulos. Aprender a leer esa doble contabilidad, y saber purgar con precisión en lugar de borrarlo todo, es lo que permite depurar una estrategia de caché en lugar de reiniciarla cada vez que algo va raro.

🎯 Al terminar esta lección sabrás
  • Distinguir en el panel de red qué respondió el worker y qué salió a la red.
  • Ver las peticiones que el propio worker origina y no solo las de la página.
  • Purgar de forma selectiva y explicar la consecuencia de cada nivel de purga.
  • Depurar una estrategia de caché comprobando qué entra y qué sale.

La doble contabilidad del panel de red

Con un worker activo, cada petición de la página puede aparecer de dos maneras.

Servida por el worker desde caché. La columna de tamaño muestra una indicación de service worker en lugar de un número de bytes, y el desglose de tiempos incluye una fase inicial correspondiente al worker. No hubo tráfico.

Servida por el worker desde la red. Aparecen dos entradas: la petición de la página, atribuida al worker, y la petición que el worker hizo a la red. Es la misma URL dos veces y no es un error de conteo: son dos operaciones distintas, una de la página al worker y otra del worker al servidor.

La columna de iniciador es la que separa las dos: las peticiones originadas por el worker se identifican como tales.

Hay además un caso que confunde mucho: si el worker está dormido, la primera petición incluye el tiempo de arrancarlo. En el desglose de tiempos aparece como una fase de inicio del worker que puede ser de decenas o cientos de milisegundos. Es una de las razones por las que la primera interacción tras un rato de inactividad puede ser más lenta, y solo se ve mirando ese desglose.

💡
Tip

El filtro del panel de red acepta filtrar por peticiones originadas en el worker. Eso convierte la lista mezclada en dos listas legibles: lo que pide la página y lo que pide el worker. Es el primer movimiento al depurar una estrategia, porque la diferencia entre las dos listas es exactamente lo que la caché está resolviendo.

Purgar con precisión

Hay tres niveles de purga y elegir el más agresivo por costumbre destruye información útil.

Nivel uno: una entrada concreta. Desde el panel, seleccionar la entrada y borrarla. Es lo que hay que hacer para comprobar si un recurso concreto se vuelve a cachear correctamente.

Nivel dos: una caché entera. Útil cuando la estrategia usa varias cachés con nombres distintos —armazón, imágenes, datos— y solo una está dando problemas.

Nivel tres: todo el estado del origen. La sección de almacenamiento del panel de aplicación tiene una acción que borra todo: almacenamiento web, IndexedDB, cachés, y da de baja los service workers. Es el botón de emergencia, y hay que usarlo sabiendo que destruye toda la evidencia. Si estás diagnosticando algo, toma nota de lo que hay antes de pulsarlo.

Desde código, los tres niveles son igual de accesibles:

// Purga por niveles, del mas fino al mas grueso
const purga = {
  async entrada(nombreCache, url) {
    const cache = await caches.open(nombreCache);
    const borrado = await cache.delete(url);
    console.log(borrado ? 'Entrada eliminada:' : 'No estaba en cache:', url);
    return borrado;
  },

  async porPatron(nombreCache, patron) {
    const cache = await caches.open(nombreCache);
    const claves = await cache.keys();
    const coinciden = claves.filter(k => patron.test(k.url));
    await Promise.all(coinciden.map(k => cache.delete(k)));
    console.log('Eliminadas', coinciden.length, 'entradas que coinciden con', patron);
    return coinciden.length;
  },

  async cache(nombre) {
    const ok = await caches.delete(nombre);
    console.log(ok ? 'Cache eliminada: ' + nombre : 'No existia: ' + nombre);
    return ok;
  },

  async todasLasCaches() {
    const nombres = await caches.keys();
    await Promise.all(nombres.map(n => caches.delete(n)));
    console.log('Eliminadas', nombres.length, 'caches:', nombres.join(', '));
    return nombres.length;
  },

  async todoElOrigen() {
    const registros = await navigator.serviceWorker.getRegistrations();
    await Promise.all(registros.map(r => r.unregister()));
    await this.todasLasCaches();
    localStorage.clear();
    sessionStorage.clear();
    const bases = await indexedDB.databases?.() ?? [];
    await Promise.all(bases.map(b => new Promise(res => {
      const req = indexedDB.deleteDatabase(b.name);
      req.onsuccess = req.onerror = req.onblocked = () => res();
    })));
    console.log('Estado del origen eliminado. Recarga para empezar de cero.');
  }
};

window.purga = purga;
console.log('Disponible: purga.entrada, purga.porPatron, purga.cache, purga.todasLasCaches, purga.todoElOrigen');

La última función es exactamente el botón de pánico que conviene tener en cualquier aplicación con service worker, expuesto en una ruta interna. Cuando un usuario está bloqueado por estado corrupto, la instrucción “entra en esta dirección y pulsa el botón” es infinitamente mejor que explicarle cómo se abren las DevTools.

Depurar una estrategia

Una estrategia de caché es una función que decide, para cada petición, de dónde sacar la respuesta. Depurarla consiste en comprobar dos cosas: qué decisión toma para cada petición, y qué acaba entrando en la caché.

La instrumentación que lo hace visible es un registro dentro del propio manejador:

// Dentro del service worker: estrategia instrumentada y legible en la consola
const REGISTRO = true;
const registrar = (...a) => { if (REGISTRO) console.log('[sw]', ...a); };

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

  // Solo intervenimos en lo nuestro: todo lo demas pasa de largo
  if (url.origin !== self.location.origin) return;
  if (evento.request.method !== 'GET') return;

  // Documentos: red primero con respaldo de cache. Evita servir HTML viejo.
  if (evento.request.mode === 'navigate') {
    evento.respondWith((async () => {
      try {
        const red = await fetch(evento.request);
        const cache = await caches.open('documentos-v1');
        cache.put(evento.request, red.clone());
        registrar('documento desde RED', url.pathname);
        return red;
      } catch {
        const guardado = await caches.match(evento.request);
        registrar('documento desde CACHE (sin red)', url.pathname);
        return guardado || caches.match('/sin-conexion.html');
      }
    })());
    return;
  }

  // Estaticos versionados: cache primero. Su URL cambia si cambia el contenido.
  if (/\.[0-9a-f]{8}\.(js|css|woff2)$/.test(url.pathname)) {
    evento.respondWith((async () => {
      const guardado = await caches.match(evento.request);
      if (guardado) { registrar('estatico desde CACHE', url.pathname); return guardado; }
      const red = await fetch(evento.request);
      const cache = await caches.open('estaticos-v1');
      cache.put(evento.request, red.clone());
      registrar('estatico desde RED y guardado', url.pathname);
      return red;
    })());
    return;
  }

  registrar('sin estrategia, pasa a la red', url.pathname);
});

El registro se ve en la consola del propio worker, que se abre desde el enlace de inspección del panel de service workers. Esa consola es un contexto de ejecución distinto del de la página, con su propio ámbito global y sus propias herramientas.

Fíjate en las dos primeras comprobaciones del manejador. No interceptar lo que no es tuyo y no interceptar métodos que no sean de lectura evitan dos categorías enteras de bugs: romper peticiones a terceros que dependen de sus propias cabeceras, e intentar cachear envíos de formulario, que además el almacén de respuestas rechaza.

Y la decisión de servir los documentos con red primero es la que evita el problema del despliegue antiguo permanente, que es el tema de la lección siguiente.

El worker es el único punto del sistema donde una decisión de una línea afecta a todas las peticiones para siempre

Merece la pena tener presente la asimetría de riesgo de este manejador comparado con cualquier otro código de la aplicación. Un error en un componente rompe una pantalla; un error en el manejador de peticiones del worker puede romper la aplicación entera, para todos los usuarios que ya lo tengan instalado, de forma que ningún despliegue posterior corrige. Es el único punto del sistema con esa propiedad, y se debe a la combinación de tres cosas: intercepta todo, persiste entre sesiones, y decide él mismo cómo se actualiza. Un manejador que por un error de lógica devuelve una respuesta vacía para el documento deja a los usuarios con una página en blanco y sin ninguna forma de recibir la corrección, porque la corrección viaja en un documento que el propio worker está sustituyendo por una respuesta vacía. Hay un número reducido de reglas defensivas que eliminan casi todo ese riesgo y que conviene aplicar sin excepción. Una: no interceptes lo que no necesitas interceptar. Un manejador que devuelve inmediatamente para todo lo que no coincida con un patrón explícito y estrecho tiene una superficie de fallo minúscula. La tentación contraria —interceptar todo y decidir dentro— multiplica los casos que pueden salir mal. Dos: que la caché sea siempre un respaldo y nunca la única fuente de la respuesta de un documento. Si la red funciona, gana la red. Eso convierte cualquier error de caché en un problema de rendimiento en vez de en un problema de corrección. Tres: envuelve la lógica en un manejador de errores que caiga a la red. Una excepción no capturada dentro del manejador produce un fallo de petición; un bloque que capture cualquier error y devuelva la petición original a la red convierte cualquier bug futuro en un funcionamiento degradado pero correcto. Y cuatro: que el worker sepa desactivarse. Una comprobación de una señal remota, o simplemente una condición sobre una versión mínima, que haga que el worker se dé de baja y limpie sus cachés, es un seguro barato que solo se echa de menos el día en que hace falta, y ese día no hay ninguna alternativa.