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ègle | Avant (v2) | Après (v3) |
|---|---|---|
/_astro/* | cache-first | non intercepté |
| Cross-origin, non-GET | interceptés | non interceptés |
Erreur de fetch() | rejet → « unexpected error » | réponse 504 explicite |
| Réponses mises en cache | toutes, 4xx/5xx compris | 2xx seulement |
| Nom du cache | app-cache-v2 | app-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.
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.- Les fragments sont
immutablepour 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
- Un service worker ne doit pas intercepter les assets hashés. Le hash dans le nom de fichier invalide déjà le cache HTTP ; le service worker n’ajoute qu’un risque de cache périmé.
- Chaque
fetch()d’un service worker a besoin d’uncatch. Sans lui, un rejet devient « ServiceWorker intercepted the request and encountered an unexpected error ». - Ne mettre en cache que les réponses 2xx. Un 404 mis en cache survit au correctif côté serveur.
unregister()n’agit qu’à la fermeture du dernier onglet. Purger, fermer toutes les pages du domaine, puis rouvrir.- Un HTML sans en-tête de cache peut figer une app sur ses vieux fragments. Un paramètre
?cache-bust=force une requête neuve. - Avant de corriger un bug de recette, vérifier quelle version du JS a été testée. Un cache périmé fabrique des défauts disparus de la source.