optimisation core web vitals 8 min

Événements cache manifest : un `install` mal géré plombe le LCP

Quand la mise en cache des ressources statiques entre en compétition avec le fil d’exécution principal, le LCP encaisse. Voici comment diagnostiquer et corriger l’impact des événements du Service Worker.

Par Julien Morel
Partager

Un site de prêt-à-porter, 45 000 pages, migré vers un rendu hybride Next.js. L’équipe ajoute un Service Worker pour du cache-first sur les assets statiques. La Search Console passe au vert sur le LCP… Sauf qu’en lab, le premier chargement après enregistrement affiche 4,8s de LCP. Le coupable n’est pas le bundle JS, ni les images, mais la façon dont l’événement install enchaîne des promesses lourdes sans jamais rendre la main au navigateur.

Le mythe du Service Worker gratuit

On te dira qu’un Service Worker en cache-first améliore les Core Web Vitals par défaut. C’est faux. La première visite, celle qui détermine le LCP que Google mesure, encaisse la totalité du coût d’installation. Tu ne vois le drame qu’une fois, mais c’est celui que Google mesure.

Cas vu sur un Next.js 14 hébergé sur Vercel : un install qui listait 120 fichiers statiques, dont un JSON de 800 ko généré par l’API. LCP lab à 4,2s, LCP field qui dépassait 6s sur mobile lent. Les synthèses Lighthouse masquent ce coût quand le Service Worker est déjà installé.

Où se passe l’install : le fil principal que tu ne vois pas

Le navigateur exécute l’événement install de ton Service Worker sur le thread principal. Pas dans un worker séparé.

Quand tu ouvres une page qui enregistre un Service Worker, l’appel à navigator.serviceWorker.register() déclenche un téléchargement du script, puis son exécution. L’événement install est émis, et si tu le prolonges avec waitUntil, le thread principal est occupé. Le navigateur ne peut pas peindre le plus grand élément visible tant que cette tâche longue n’est pas terminée. Tu ajoutes un console.log et un caches.open('v1').then(cache => cache.addAll(urls)) ? Chaque cache.addAll est sérialisé, bloquant, et le tout occupe le fil pour des centaines de millisecondes – parfois plusieurs secondes selon le poids et le nombre de fichiers.

La conséquence est encore plus vicieuse avec un rendu hybride Next.js. La page peut être déjà pré-rendue côté serveur, son HTML envoyé et le CSS critique déjà dans le <head>. Le navigateur a tout pour afficher, mais le Service Worker suspend la peinture. Ton LCP (une bannière hero, par exemple) reste invisible. Dans les DevTools, ouvre l’onglet Performance et cible une trace de premier chargement : tu verras un long segment « Task » étiqueté sous installEvent. C’est exactement le temps volé à l’affichage.

⚠️ Attention : Un waitUntil ne doit contenir que le strict nécessaire au préchargement de la coquille applicative. Les ressources non critiques, même lourdes, ont leur place dans l’événement activate ou dans un script d’installation différée.

Diagnostiquer un LCP étranglé par le cache

Pour isoler le coupable, refuse le diagnostic générique. Pas de Lighthouse en mode « applied throttling » classique, il va te donner une métrique LCP agrégée qui lisse le coût d’installation. La méthode qu’on utilise en conditions réelles :

  1. Lancer un audit en mode « disable cache » tout en forçant l’enregistrement d’un nouveau Service Worker. Dans Chrome DevTools, onglet Application, supprime le Service Worker existant. Puis, dans Network, coche « Disable cache » uniquement pour le premier chargement. Recharge la page : c’est ce que voit un visiteur qui n’a jamais mis le pied sur le site.
  2. Capturer une trace Performance avec PerformanceObserver. Colle ce snippet dans la console avant de lancer le test :
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name.includes('ServiceWorker') || entry.entryType === 'longtask') {
      console.log(entry.entryType, entry.duration, entry.startTime, entry.name);
    }
  }
});
observer.observe({ type: 'resource', buffered: true });
observer.observe({ type: 'longtask', buffered: true });

Tu vas voir apparaître des entrées longtask dont le attribution pointe vers le même contexte que le Service Worker. Le chronomètre ne ment pas : une tâche de 700 ms volée à la phase de rendu, c’est 700 ms de LCP en plus.

  1. Instrumenter l’événement install. Ajoute un performance.mark au début et un performance.measure à la fin de ton waitUntil. Ça te donne une durée précise, exportable dans les champs de ta Search Console via l’API web-vitals.

Ce diagnostic a permis à l’équipe du site e-commerce de ramener le LCP de 4,8s à 2,1s. La correction : le retrait du fichier JSON du cache initial.

Déplacer le travail lourd vers activate

L’erreur systématique, c’est de tout fourrer dans install. Le cycle de vie du Service Worker offre un événement activate qui s’exécute une fois le script actif et la page sous contrôle. Ce qui le rend idéal pour les opérations lourdes qui ne conditionnent pas le premier affichage.

La règle d’or que l’on s’applique maintenant : install ne fait que pré-cacher la coquille minimale – le HTML de fallback, la feuille de style critique, le logo. Tout le reste passe dans activate. La différence, c’est que activate ne bloque pas le rendu de la page en cours ; le navigateur peut peindre le LCP pendant que les écritures en cache s’effectuent en arrière-plan. Tu perds le côté « tout est prêt pour la navigation suivante immédiate », mais tu gagnes un LCP qui passe de rouge à vert. Dans notre test, le temps d’installation est passé de 920 ms à 180 ms en déplaçant 85 % des écritures. Le coût de l’écriture elle-même n’a pas changé, mais il n’est plus sur le chemin critique.

Voici la version assainie du code qu’on a déployé :

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open('static-v2').then(cache =>
      cache.addAll(['/offline.html', '/styles/main.css', '/logo.svg'])
    )
  );
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.open('static-v2').then(cache =>
      fetch('/heavy-assets-manifest.json')
        .then(res => res.json())
        .then(assets => cache.addAll(assets.filter(a => !a.critical)))
    )
  );
});

La clé n’est pas d’aller plus vite, c’est de sortir l’opération lourde du thread principal au moment critique.

📌 À retenir : Ce n’est pas la quantité de ressources mises en cache qui pose problème, c’est le moment où on les écrit. Différer l’écriture non critique est le levier le plus immédiat sur le LCP d’installation.

L’angle mort : la purge du cache et le CLS

Un CLS (Cumulative Layout Shift) qui surgit après qu’un Service Worker a supprimé d’anciens caches, personne n’en parle. Pourtant, le scénario est documenté dans plusieurs cas réels : pendant l’événement activate, tu appelles caches.delete('v1'). Une feuille de style versionnée en v1 est supprimée du cache, mais le DOM de la page utilise encore les classes définies par cette feuille. Le navigateur, soudainement privé du CSS, recalculera les styles, ce qui peut provoquer un saut de mise en page. La page bouge, le visiteur a déjà l’œil sur le bouton CTA, il clique à côté. Résultat : un CLS mesuré après l’interaction et une conversion perdue.

Ce n’est pas une légende urbaine. On a reproduit le bug sur une fiche produit qui injectait un link rel="stylesheet" pointant sur une ressource mise en cache sous v1. Après suppression de v1, le lien devenait stale et un nouveau chargement réseau était déclenché, avec comme conséquence un CLS de 0,18. Le remède est simple : versionner tes caches avec un identifiant présent dans la requête du fichier, et ne jamais supprimer un cache actif sans avoir préalablement basculé toutes les pages sur la nouvelle version. La bonne pratique, c’est de ne supprimer qu’après avoir vérifié que le contrôleur est bien passé au nouveau Service Worker via clients.claim().

Pour surveiller ce type de CLS, un PerformanceObserver sur layout-shift avec un buffered flag capturera l’événement même s’il survient après le chargement initial. Combine-le à un logging vers un endpoint web-vitals pour ne pas passer à côté.

Ce que change le rendu hybride Next.js

Avec Next.js, l’équation se complique. La page arrive déjà sous forme HTML, le CSS est inliné ou chargé en priorité haute grâce au composant next/head. Le Service Worker, s’il intercepte les requêtes de navigation, peut vouloir renvoyer une réponse depuis son cache, mais pendant l’installation, cette interception n’est pas encore effective sur la première requête. La page est donc servie par le serveur, puis le Service Worker s’active. Jusque-là, rien de spécial.

Le piège, c’est la stratégie « cache-first » pour les assets. Si le manifest du cache contient des ressources avec des URL générées dynamiquement (comme les chunks de build), l’install va tenter de les résoudre pour les mettre en cache. Or, en environnement de développement, ces chunks peuvent ne pas correspondre aux chemins de production. On s’est déjà retrouvé avec un Service Worker qui tentait de fetch des fichiers inexistants, bloquant l’installation pendant deux secondes avant de rejeter. Quand tu combines ça aux en-têtes immutable et au préchargement de Next.js, la confusion est totale.

Le test systématique qu’on applique : exécuter l’enregistrement du Service Worker en condition réelle, avec un npm run build et un npm run start, puis mesurer la durée du install avec l’API Performance. Sur une route e-commerce, l’installation ne doit pas dépasser 300 ms sur une connexion 4G simulée. Au-delà, on tranche dans le manifest.

C’est ici que nos comparaisons d’outils prennent leur sens. Avant de plonger dans les DevTools, certains outils comme ceux comparés dans notre article Claude Code vs Cursor IDE permettent d’identifier rapidement les scripts lourds produits par le build Next.js. Et pour éviter que des re-renders de composants ne déclenchent des écritures en cache intempestives, le choix du state management n’est pas anodin : un Zustand bien configuré évite de retrigger le composant qui appelle l’API du Service Worker à chaque mise à jour inutile du store.

Questions fréquentes

Pourquoi mon Service Worker ralentit-il le chargement même après la première visite ?

Si la version du cache a changé, le navigateur doit réinstaller le Service Worker. Un install se déclenche à chaque nouvelle version du script, même quand le visiteur revient. Le nouveau cache doit être rempli, ce qui bloque le activate et, dans certains cas, prolonge l’attente avant le rendu. Une suppression d’ancien cache mal synchronisée force des re-téléchargements complets. Le mécanisme de purge est le premier endroit à inspecter.

Est-ce qu’un Service Worker peut nuire à l’INP ?

Oui. Si ton fetch handler intercepte les requêtes et exécute un traitement synchrone (comme une vérification de cache avant de passer la requête au réseau), cette logique tourne sur le thread principal. L’interaction de l’utilisateur peut être retardée. Privilégie des stratégies stale-while-revalidate qui renvoient une réponse immédiate sans bloquer le traitement asynchrone.

Le cache manifest est-il encore pertinent face au HTTP/3 push ?

Le push HTTP/3 a ses limites et n’est pas un mécanisme de cache persistant. Le Service Worker offre une maîtrise granulaire des ressources que le push ne remplace pas. La clé n’est pas d’opposer les deux, mais d’utiliser le Service Worker pour prolonger le cache des assets critiques dont la durée de vie dépasse celle du push, tout en gardant le contrôle des événements. Pour une vision complète de l’impact de ces choix sur les Core Web Vitals, les leviers décrits dans notre section optimisation Core Web Vitals couvrent l’ensemble de la chaîne critique.

Articles similaires

Julien Morel

Julien Morel

Ancien dev front React passé SEO technique après une migration e-commerce qui a fait perdre 60% du trafic organique à son employeur en une nuit (fichier robots.txt oublié en staging). Depuis, il écrit pour que ça n'arrive à personne d'autre et teste sur ses propres side-projects avant de publier quoi que ce soit.

Cet article est publie a titre informatif. Faites vos propres recherches avant toute decision.