Aller au contenu
Sylvain Joffraud l4w
Retour

Service worker et `/_astro/` : l’app figée au chargement

Un service worker qui intercepte les assets /_astro/ d’Astro a figé l’inscription d’une PWA de signalements sur « Chargement… » juste après un déploiement. La cause : le service worker cherchait un fichier /_astro/<file>.<hash>.js absent de son cache, et son fetch() n’avait pas de catch ; le navigateur recevait une erreur à la place du module, et le composant ne s’hydratait jamais. Contexte : Astro 6.1, Svelte 5, Supabase, offline-first avec Dexie. Quatre mois plus tard, la purge des caches a réservé une seconde surprise.

Le symptôme : « ServiceWorker intercepted the request and encountered an unexpected error »

Un service worker est un script que le navigateur exécute entre la page et le réseau : il reçoit un événement fetch par requête et peut y répondre lui-même avec event.respondWith(). Sur staging, en mai 2026, après un nouveau build, le parcours d’inscription restait bloqué sur « Chargement… ». La requête du module JavaScript de l’écran échouait avec ce message :

ServiceWorker intercepted the request and encountered an unexpected error

Pourquoi le service worker cassait les assets /_astro/ ?

La version 2 du sw.js appliquait une stratégie cache-first (servir depuis le cache, sinon aller au réseau) à tout ce qui n’était pas une navigation :

const CACHE_NAME = "app-cache-v2";

// Cache-first for assets
event.respondWith(
  caches.match(request).then((cached) => {
    if (cached) return cached;
    return fetch(request).then((response) => {
      const clone = response.clone();
      caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
      return response;
    });
  }),
);

Trois défauts se cumulent. Le fetch() n’a pas de catch : s’il rejette, la promesse passée à respondWith() rejette aussi, et le navigateur affiche l’« unexpected error ». La réponse est mise en cache quel que soit son statut, un 404 compris. Enfin, ce cache est inutile sur /_astro/ : Astro nomme chaque fichier buildé avec un hash de son contenu, donc un nouveau contenu a une nouvelle URL, et le cache HTTP du navigateur fait déjà le travail.

Un fichier dont le nom change avec le contenu n’a rien à faire dans un cache de service worker : le cache HTTP l’invalide déjà, et le service worker ne peut qu’y ajouter un état périmé.

Le correctif : ne plus intercepter ce qui est déjà cache-busté

La version 3 laisse passer /_astro/, les requêtes cross-origin (Supabase, polices, envoi d’e-mails) et tout ce qui n’est pas GET, en retournant sans appeler respondWith() : le navigateur fait alors son fetch natif.

const CACHE_NAME = "app-cache-v3";

self.addEventListener("fetch", (event) => {
  const { request } = event;
  const url = new URL(request.url);

  if (url.pathname.startsWith("/_astro/")) return; // fetch natif
  if (url.origin !== self.location.origin) return;
  if (request.method !== "GET") return;

  if (request.mode === "navigate") {
    // réseau d'abord pour le HTML, repli sur le cache hors ligne
    // ...
  } else {
    // cache-first, désormais limité au manifeste, au favicon et aux icônes
    event.respondWith(
      caches.match(request).then((cached) => {
        if (cached) return cached;
        return fetch(request)
          .then((response) => {
            if (response.ok && response.status < 400) {
              const clone = response.clone();
              caches.open(CACHE_NAME).then((c) => c.put(request, clone)).catch(() => {});
            }
            return response;
          })
          .catch((err) => new Response(`SW fetch failed: ${err.message}`, { status: 504 }));
      }),
    );
  }
});
RègleAvant (v2)Après (v3)
/_astro/*cache-firstnon intercepté
Cross-origin, non-GETinterceptésnon interceptés
Erreur de fetch()rejet → « unexpected error »réponse 504 explicite
Réponses mises en cachetoutes, 4xx/5xx compris2xx seulement
Nom du cacheapp-cache-v2app-cache-v3 (purge à l’activation)

Le changement de CACHE_NAME sert de purge : l’événement activate supprime tout cache dont le nom diffère.

⚠️ Dans la branche navigation, un caches.match(...).then(...) || new Response("Offline", { status: 503 }) ne renvoie jamais la 503 : une promesse est toujours vraie en JavaScript.

Pourquoi la purge du service worker ne suffit pas

En septembre 2026, en vérifiant un correctif sur la prod, trois purges successives (désinscription du service worker, vidage des caches) ont continué de charger un fragment qui renvoyait pourtant 404 depuis le serveur. Deux mécanismes se superposaient.

  1. unregister() est différé. La méthode rend la main tout de suite, mais la désinscription n’a lieu qu’à la fermeture du dernier client. Un autre onglet ouvert sur le même domaine garde le service worker en vie.
  2. Les fragments sont immutable pour un an, selon la configuration nginx de l’app :
location /_astro/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
}

Le HTML, lui, n’a aucun en-tête de cache : sa fraîcheur est heuristique, c’est-à-dire que le navigateur décide seul combien de temps le resservir sans demander au serveur. Si le HTML sort du cache, l’application entière tourne sans toucher au serveur. Le contournement : forcer une requête neuve sur le HTML, qui référencera les nouveaux fragments.

https://app.example.com/app/?cache-bust=<horodatage>

Ce piège épargne un vrai visiteur (navigation en réseau d’abord, skipWaiting() et clients.claim()) : il touche les profils de navigateur réutilisés d’une vérification à l’autre.

La fausse piste : un défaut d’accessibilité qui n’existait pas

Une recette de septembre signalait cinq boutons radio sans nom accessible et des cases à cocher qui s’annonçaient « on ». Une semaine plus tard, le cache app-cache-v3 était encore présent au début de la session et servait du JS antérieur : l’arbre d’accessibilité montrait un dépliant « Préciser ▾ » qui n’existait plus dans la source. Après purge, les noms étaient corrects partout. Ajouter des aria-label aurait doublé des noms déjà justes.

Lessons learned

Questions fréquentes

Que signifie « ServiceWorker intercepted the request and encountered an unexpected error » ?
Ce message indique que le service worker a pris en charge une requête avec event.respondWith() mais n’a pas fourni de réponse valide : la promesse passée a été rejetée, typiquement par un fetch() sans catch. Le navigateur traite alors la requête comme une erreur réseau. Si la requête visait un module JavaScript, le composant qui en dépend ne s’hydrate jamais.
Faut-il mettre les fichiers <code>/_astro/</code> dans le cache du service worker ?
Les fichiers /_astro/ d’un build Astro portent un hash dans leur nom : chaque nouveau contenu a une nouvelle URL. Le cache HTTP du navigateur suffit, avec un en-tête Cache-Control: public, immutable. Les intercepter dans un service worker cache-first n’apporte rien et expose à un cache périmé incapable de servir un fichier attendu juste après un déploiement.
Pourquoi <code>unregister()</code> ne supprime-t-il pas tout de suite le service worker ?
La méthode unregister() rend la main immédiatement, mais la désinscription ne prend effet qu’à la fermeture du dernier client contrôlé par ce service worker. Un autre onglet ouvert sur le même domaine le maintient en vie. Pour une purge effective, il faut désinscrire, vider les caches, fermer toutes les pages du domaine, puis en rouvrir une.
Comment voir la nouvelle version d’une app Astro malgré le cache du navigateur ?
Quand le HTML n’a pas d’en-tête de cache, le navigateur peut le resservir de lui-même et l’application tourne alors sur d’anciens fragments /_astro/. Le plus simple est de forcer une requête neuve sur le HTML avec un paramètre inédit, par exemple ?cache-bust=<horodatage> : le HTML revient du serveur et référence les nouveaux fragments, absents du cache.

Partager cet article :

Article précédent
z-index 10000 : menus MUI vides dans un Drawer
Article suivant
LLM local : préchauffer le prompt au déploiement