wandres.dev
SERVICE WORKERS EN DEVTOOLS · Depurar el SW

Provocar eventos: modo desconectado, push y sincronización

Los disparadores del panel que permiten probar sin red, sin servidor de notificaciones y sin esperar a que el sistema decida, y qué simula cada uno con exactitud.

⏱ 17 min

Un service worker responde a eventos que en la vida real dependen de condiciones que no controlas: que se caiga la red, que llegue una notificación desde un servidor, que el sistema decida que hay conectividad suficiente para reintentar algo pendiente. Esperar a que esas condiciones ocurran haría imposible el desarrollo. El panel incluye disparadores para las tres, y usarlos bien exige saber exactamente qué simula cada uno y en qué se diferencia de la situación real.

🎯 Al terminar esta lección sabrás
  • Probar el comportamiento sin red con el modo desconectado del panel.
  • Disparar un evento de notificación con carga útil sin servidor.
  • Disparar eventos de sincronización en segundo plano y periódica.
  • Distinguir lo que cada disparador simula de lo que ocurre en producción.

Modo desconectado

La casilla de modo desconectado del panel de service workers, y su equivalente en el panel de red, hacen que todas las peticiones fallen como si no hubiera conexión.

Es la prueba fundamental de cualquier estrategia de funcionamiento sin red, y tiene una diferencia importante respecto a desconectar el cable o el adaptador: la emulación afecta solo a esta pestaña, así que puedes seguir consultando documentación mientras pruebas.

El procedimiento correcto tiene cuatro pasos y casi todo el mundo se salta el tercero.

Uno: carga la aplicación con red y navega por las secciones que quieras probar, para que el worker cachee lo que le corresponda según su estrategia.

Dos: activa el modo desconectado.

Tres: recarga. Este es el paso que se salta la gente, y es el que de verdad prueba algo. Sin recargar, la página que tienes en pantalla sigue funcionando porque ya está cargada en memoria, y solo estarías probando las peticiones nuevas.

Cuatro: navega y usa la aplicación. Cada cosa que falle es un caso que el worker no cubre.

⚠️
Cuidado

El modo desconectado del panel es más severo que muchas situaciones reales, y esa severidad es una ventaja. Una red real degradada no falla: se queda esperando, que es un caso peor y que hay que probar aparte con la ralentización extrema. Lo ideal es probar los dos: desconectado para comprobar que las rutas de error existen, y muy lento para comprobar que hay tiempos límite y avisos al usuario.

Notificaciones de inserción

El botón de inserción del panel envía un evento al worker con la carga útil que escribas en el campo contiguo. No hay servidor, no hay suscripción, no hay claves: es el evento directo.

Eso permite desarrollar el manejador completo sin montar la infraestructura, que es donde se va la mayor parte del tiempo de esta funcionalidad.

// En el service worker: manejador completo de una notificacion entrante
self.addEventListener('push', (evento) => {
  // La carga puede no ser JSON: el boton del panel envia texto plano
  let datos = { titulo: 'Novedad', cuerpo: 'Tienes algo nuevo', url: '/' };
  if (evento.data) {
    try { datos = { ...datos, ...evento.data.json() }; }
    catch { datos.cuerpo = evento.data.text(); }
  }

  evento.waitUntil(
    self.registration.showNotification(datos.titulo, {
      body: datos.cuerpo,
      icon: '/icono-192.png',
      badge: '/insignia-72.png',
      tag: datos.tag || 'general',          // agrupa y sustituye las del mismo tag
      renotify: false,
      data: { url: datos.url },             // lo que leera el manejador del clic
      actions: [
        { action: 'abrir', title: 'Abrir' },
        { action: 'descartar', title: 'Descartar' }
      ]
    })
  );
});

self.addEventListener('notificationclick', (evento) => {
  evento.notification.close();
  if (evento.action === 'descartar') return;

  const destino = evento.notification.data?.url || '/';
  evento.waitUntil((async () => {
    const clientes = await self.clients.matchAll({
      type: 'window', includeUncontrolled: true
    });
    // Si ya hay una pestaña del sitio abierta, enfocarla en lugar de abrir otra
    for (const c of clientes) {
      if (new URL(c.url).origin === self.location.origin) {
        await c.focus();
        if ('navigate' in c) await c.navigate(destino);
        return;
      }
    }
    await self.clients.openWindow(destino);
  })());
});

Lo que este disparador no simula es todo el camino previo: la suscripción del usuario, el cifrado de la carga, el servicio de mensajería del navegador, y la entrega a un dispositivo dormido. Esa parte hay que probarla de verdad, y su modo de fallo más común es la caducidad de la suscripción, que solo se detecta en producción.

La lógica de enfocar una pestaña existente en lugar de abrir una nueva es el detalle que más se agradece: sin ella, cada notificación pulsada abre otra pestaña del mismo sitio, y el usuario acaba con siete.

Sincronización en segundo plano

El botón de sincronización dispara un evento con la etiqueta que escribas. El mecanismo real está pensado para una situación concreta: el usuario hace algo sin conexión y quieres completarlo cuando vuelva la red, aunque para entonces haya cerrado la pestaña.

// Registro desde la pagina cuando una accion no se puede completar ahora
async function encolarParaCuandoHayaRed(tarea) {
  await guardarEnIndexedDB('pendientes', tarea);   // persistir primero, siempre
  const registro = await navigator.serviceWorker.ready;
  if ('sync' in registro) {
    await registro.sync.register('enviar-pendientes');
    console.log('Encolado. Se enviara cuando haya conexion.');
  } else {
    console.log('Sin soporte de sincronizacion: se reintentara al volver a cargar.');
  }
}

// En el service worker
self.addEventListener('sync', (evento) => {
  if (evento.tag !== 'enviar-pendientes') return;
  evento.waitUntil((async () => {
    const pendientes = await leerTodo('pendientes');
    for (const t of pendientes) {
      try {
        const r = await fetch('/api/enviar', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(t.datos)
        });
        if (r.ok) await borrar('pendientes', t.id);
        else if (r.status < 500) await borrar('pendientes', t.id);   // no reintentar errores del cliente
      } catch {
        // Rechazar el evento hace que el navegador reintente mas tarde
        throw new Error('Sin red todavia');
      }
    }
  })());
});

Dos aspectos que este disparador esconde y que hay que tener en cuenta.

El reintento es del navegador, no tuyo. Si el manejador rechaza, el navegador vuelve a intentarlo con su propia política de espera creciente. Eso significa que el manejador tiene que ser idempotente: puede ejecutarse varias veces para la misma tarea, y enviar dos veces el mismo pedido es peor que no enviarlo.

El evento puede no llegar nunca. El soporte no es universal y el navegador puede decidir no ejecutarlo. Cualquier diseño que dependa de él como único mecanismo de entrega es frágil; la cola persistida en IndexedDB tiene que reintentarse también en el arranque normal de la aplicación.

La sincronización periódica es la variante para actualizaciones de fondo recurrentes, con su propio disparador en el panel. Su disponibilidad es todavía más limitada y su frecuencia real la decide el navegador según cuánto use el usuario tu sitio, así que no sirve para nada que tenga que ocurrir con una cadencia concreta.

La tabla de lo que simula cada uno

Disparador Simula bien No simula
Modo desconectado Fallo inmediato de todas las peticiones Red lenta, pérdida parcial, recuperación
Inserción El evento y su carga en el worker Suscripción, cifrado, entrega, caducidad, permisos
Sincronización La ejecución del manejador La política de reintento y su espera creciente
Sincronización periódica La ejecución del manejador La frecuencia real, que decide el navegador

Esa columna de la derecha es la que hay que tener presente al dar algo por probado. Los disparadores prueban tu código; no prueban la plataforma ni la infraestructura.

Sin red no es un estado binario, y modelarlo como tal es la causa de las peores experiencias móviles

La palabra que usa la plataforma —desconectado— sugiere un interruptor con dos posiciones, y esa simplificación es la raíz de casi todos los fallos de las aplicaciones que dicen funcionar sin red. La realidad de una conexión móvil tiene al menos cinco estados y cada uno exige un comportamiento distinto. Conectado y rápido: todo normal. Conectado y lento: las peticiones salen y tardan diez o veinte segundos; el indicador de conectividad del navegador dice que hay red, tu código cree que todo va bien, y el usuario ve un cargador girando eternamente. Es el estado peor y el menos probado. Conectado a una red sin salida, el caso del portal cautivo de un hotel o un aeropuerto: hay conexión, las peticiones se resuelven, y devuelven la página de login del portal en lugar de tus datos. Tu código recibe una respuesta con estado de éxito y un cuerpo que no es lo que pidió, y si no valida la forma de lo que recibe, guarda basura en su caché. Intermitente: funciona y deja de funcionar cada pocos segundos, típico del metro o de un ascensor; aquí lo que rompe las aplicaciones no es el fallo sino la recuperación mal manejada, con reintentos que se acumulan y peticiones duplicadas. Desconectado del todo: el único estado que la gente prueba, y el más benigno de los cinco porque falla rápido y explícitamente. La consecuencia de diseño es que la propiedad del navegador que indica si hay conexión es prácticamente inútil: informa de si hay una interfaz de red activa, no de si tu servidor es alcanzable, así que da verdadero en el portal cautivo y en la red lenta, que son los dos casos donde más falta haría. Lo único que funciona de verdad es medir el resultado: mantener un indicador de salud propio basado en si las últimas peticiones han tenido éxito y en cuánto han tardado, con un tiempo límite explícito en todas ellas, y presentar al usuario un estado derivado de esa medición y no de la propiedad del navegador. Y una regla que resume toda la sección: cualquier petición sin tiempo límite es un bug en móvil, porque el navegador no impone ninguno y una petición puede quedarse viva indefinidamente, ocupando conexión y dejando la interfaz esperando algo que no va a llegar.