Commencez par les données terrain, pas par un score Lighthouse. Repérez dans Search Console ou PageSpeed Insights la métrique qui échoue au 75e centile et le type d’appareil concerné, puis ajoutez le build d’attribution de web-vitals pour que les vraies sessions désignent l’élément, l’interaction ou le décalage responsable. Corrigez le LCP par sa plus grande sous-partie, l’INP par la tâche longue derrière les interactions lentes, et le CLS en réservant de la place à tout ce qui bouge. Les outils de laboratoire reproduisent un problème et vérifient un correctif, mais ne décident pas si vous passez l’évaluation.
Les trois métriques et leurs seuils
La présentation des Web Vitals sur web.dev liste trois Core Web Vitals stables :
- Largest Contentful Paint (LCP), le chargement : bon à 2,5 secondes ou moins, mauvais au-delà de 4 secondes.
- Interaction to Next Paint (INP), la réactivité : bon à 200 millisecondes ou moins, mauvais au-delà de 500 millisecondes.
- Cumulative Layout Shift (CLS), la stabilité visuelle : bon à 0,1 ou moins, mauvais au-delà de 0,25.
Les valeurs intermédiaires sont classées « à améliorer ». Les seuils s’appliquent au 75e centile des chargements de page, séparément pour le mobile et le desktop, et une page ne passe que si les trois métriques sont bonnes à ce centile. Une médiane correcte peut masquer le quart des visites venues de téléphones lents, et l’évaluation existe précisément pour les voir.
L’INP a remplacé le First Input Delay en 2024. Il observe les clics, les appuis tactiles et les frappes au clavier pendant toute la vie de la page, et mesure chacun jusqu’au rendu de l’image suivante. Le survol, le zoom et le défilement ne comptent pas, et la plus lente interaction est ignorée pour chaque tranche de 50 interactions, si bien qu’une valeur aberrante isolée ne fixe pas le score.
Le README de web-vitals indique que onLCP() et onINP() fonctionnent dans Chromium, Firefox et Safari, et onCLS() uniquement dans Chromium. Les données de compatibilité de MDN montrent que Safari a ajouté les API LCP et Event Timing dans sa version 26.2. Le jeu de données terrain de Google ne provient toujours que des utilisateurs de Chrome.
Données terrain et données de laboratoire
Les données terrain viennent de visites réelles. Le Chrome UX Report (CrUX) agrège les utilisateurs Chrome éligibles sur une fenêtre glissante de 28 jours, au niveau de l’origine et de l’URL, pour les pages ayant assez de trafic public. PageSpeed Insights affiche ces données en haut du rapport et se replie sur l’origine lorsqu’une URL en a trop peu. Le rapport Core Web Vitals de Search Console utilise la même source, regroupe les URL similaires, attribue à chaque groupe le statut de sa pire métrique et suit le mobile et le desktop séparément. Un correctif déployé aujourd’hui fait évoluer ces chiffres progressivement sur quatre semaines.
Les données de laboratoire viennent d’un seul chargement contrôlé. Lighthouse charge la page sur un appareil et un réseau simulés. Le panneau Performance de DevTools affiche en direct les LCP, CLS et INP locaux, journalise les interactions avec leurs phases et les décalages de mise en page avec leur score, et peut récupérer les données CrUX avec un bridage CPU et réseau suggéré qui correspond à vos utilisateurs.
Leurs écarts sont prévisibles, et web.dev les détaille dans pourquoi les données de laboratoire et de terrain diffèrent :
- LCP : un test en laboratoire part d’un cache vide, d’une seule taille de fenêtre et sans personnalisation. Les vrais utilisateurs peuvent avoir des ressources en cache, un autre élément LCP ou une variante A/B, et les restaurations depuis le bfcache comptent sur le terrain.
- INP : une exécution Lighthouse en mode navigation n’a aucune interaction, elle rapporte donc le Total Blocking Time à la place. Le TBT aide à diagnostiquer les blocages pendant le chargement, mais ne voit pas un clic lent plus tard dans la session.
- CLS : le laboratoire voit les décalages pendant le chargement. Le CLS terrain couvre toute la vie de la page, y compris le contenu chargé à la demande sans dimensions et les publicités tardives.
Décidez de ce qui est cassé à partir des données terrain, puis reproduisez-le en laboratoire avec un bridage réaliste. L’API CrUX renvoie directement le 75e centile et se met à jour chaque jour :
curl -s --request POST \
"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
--header 'Content-Type: application/json' \
--data '{
"origin": "https://example.com",
"formFactor": "PHONE",
"metrics": [
"largest_contentful_paint",
"interaction_to_next_paint",
"cumulative_layout_shift",
"largest_contentful_paint_image_time_to_first_byte",
"largest_contentful_paint_image_resource_load_delay",
"largest_contentful_paint_image_resource_load_duration",
"largest_contentful_paint_image_element_render_delay"
]
}'
Les quatre dernières métriques sont les sous-parties du LCP pour les chargements où l’élément LCP est une image.
Collecter des données utilisateurs réels avec le build d’attribution
CrUX indique qu’une métrique échoue, rarement pourquoi. La bibliothèque web-vitals mesure les métriques comme Chrome, et son build d’attribution ajoute l’élément, la ventilation temporelle et l’état de chargement du document. La version majeure actuelle est la 6. Elle peut rapporter les soft navigations des applications monopages sur Chromium 151 et plus, mais la façon dont CrUX les comptera n’est pas encore décidée.
npm install web-vitals
Ce module conserve le dernier enregistrement de chaque instance de métrique et envoie un lot avec navigator.sendBeacon() quand la page devient masquée, comme le recommande le README :
import {onCLS, onINP, onLCP} from 'web-vitals/attribution';
const queue = new Map();
function toRecord(metric) {
const {name, value, rating, id, attribution} = metric;
const record = {name, value, rating, id, page: metric.navigationURL ?? location.href};
if (name === 'LCP') {
record.target = attribution.target;
record.ttfb = attribution.timeToFirstByte;
record.loadDelay = attribution.resourceLoadDelay;
record.loadDuration = attribution.resourceLoadDuration;
record.renderDelay = attribution.elementRenderDelay;
} else if (name === 'INP') {
record.target = attribution.interactionTarget;
record.inputDelay = attribution.inputDelay;
record.processing = attribution.processingDuration;
record.presentation = attribution.presentationDelay;
record.loadState = attribution.loadState;
record.script = attribution.longestScript?.entry.sourceURL;
} else if (name === 'CLS') {
record.target = attribution.largestShiftTarget;
record.loadState = attribution.loadState;
}
return record;
}
function flush() {
if (queue.size === 0) return;
navigator.sendBeacon('/rum', JSON.stringify([...queue.values()]));
queue.clear();
}
onLCP((metric) => queue.set(metric.id, toRecord(metric)));
onINP((metric) => queue.set(metric.id, toRecord(metric)));
onCLS((metric) => queue.set(metric.id, toRecord(metric)));
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
Le CLS et l’INP peuvent être rapportés plusieurs fois, le collecteur garde donc la dernière valeur par id. L’INP est absent si l’utilisateur n’interagit jamais. Agrégez par modèle de page et type d’appareil, puis par target, pour trouver les quelques éléments qui dominent le p75. Vos chiffres ne correspondront pas exactement à CrUX : ils incluent d’autres navigateurs, et la bibliothèque ne voit pas l’intérieur des iframes. Échantillonnez sur les sites très fréquentés, gardez une faible cardinalité des libellés et appliquez vos règles de rétention habituelles. Le guide d’observabilité des agents IA décrit la même discipline pour la télémétrie.
LCP : trouver la sous-partie lente
Le guide d’optimisation du LCP découpe le LCP en quatre sous-parties successives :
- Time to first byte (TTFB) : du début de la navigation au premier octet du HTML.
- Resource load delay : du TTFB au début de la requête de la ressource LCP.
- Resource load duration : le téléchargement lui-même.
- Element render delay : de la ressource prête à l’élément affiché.
Pour un élément LCP textuel, les deux sous-parties centrales valent zéro. Le guide propose un repère : environ 40 % chacun pour le TTFB et le téléchargement, et moins de 10 % pour chaque délai. Rapporté à 2,5 secondes, cela donne environ 1 seconde, 250 millisecondes, 1 seconde et 250 millisecondes. Un grand délai de chargement signifie une découverte tardive. Un grand délai de rendu signifie que quelque chose a bloqué l’affichage. Compresser l’image ne corrige ni l’un ni l’autre.
Les correctifs LCP qui font bouger le chiffre
Time to first byte
web.dev considère un TTFB de 0,8 seconde ou moins comme bon et au-delà de 1,8 seconde comme mauvais. Il inclut les redirections, le démarrage du service worker, le DNS, la connexion et TLS, puis la requête. Supprimez les chaînes de redirections, mettez le HTML en cache en périphérie du CDN pour le trafic anonyme, et servez les fichiers à nom versionné avec une longue durée de cache immuable. Le guide de mise en cache statique avec Nginx et Cloudflare détaille les en-têtes et les règles. Si votre origine tourne sur un petit VPS, gardez-la minimale avec la checklist de durcissement d’un VPS Linux et laissez la périphérie absorber le trafic.
curl -s -o /dev/null \
-w 'dns %{time_namelookup}\nconnect %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\n' \
https://example.com/
curl -sI https://example.com/ | grep -iE '^(cache-control|age|cf-cache-status):'
Resource load delay
L’image LCP doit commencer à se charger avec les premières ressources de la page. Placez-la dans le HTML initial sous forme de <img>, ne lui mettez jamais loading="lazy" et ajoutez fetchpriority="high", que MDN indique pour Chromium, Firefox 132+ et Safari 17.2+. Le coupable habituel est un bloc hero rendu côté client : l’image ne peut pas être demandée tant que le bundle n’est pas téléchargé, exécuté et souvent alimenté en données. Rendez le hero côté serveur ou à la génération. Préchargez une image LCP qui vient du CSS.
<link rel="preload" as="image" href="/img/hero-bg.avif"
type="image/avif" fetchpriority="high">
<picture>
<source type="image/avif"
srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
sizes="(max-width: 800px) 100vw, 800px">
<img src="/img/hero-800.jpg"
srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
sizes="(max-width: 800px) 100vw, 800px"
width="800" height="450" alt="Product dashboard"
fetchpriority="high">
</picture>
Utilisez le <link> uniquement pour une image CSS, et le <picture> pour du contenu ordinaire.
Resource load duration
Envoyez moins d’octets. Servez de l’AVIF ou du WebP avec un format de repli, laissez srcset et sizes choisir un fichier adapté à l’emplacement réel, et diffusez depuis un CDN avec une durée de cache qui épargne le téléchargement aux visites suivantes. Limitez le nombre de requêtes à haute priorité, car chacune concurrence l’image LCP.
Element render delay
Si l’image arrive tôt mais s’affiche tard, cherchez de grosses feuilles de style bloquantes, des scripts synchrones dans <head> et des polices en font-display: block ou auto. Les scripts qui masquent la page jusqu’à leur fin, comme certains outils d’expérimentation, ajoutent leur durée au LCP. Intégrez le CSS critique en ligne, différez le reste et affichez le hero sans JavaScript côté client.
INP : trouver l’interaction et sa tâche longue
Une interaction comporte trois phases. L’input delay est l’attente avant l’exécution des gestionnaires, en général derrière une autre tâche. La processing duration correspond aux gestionnaires eux-mêmes. La presentation delay va de la fin des gestionnaires à l’image suivante et inclut le style, la mise en page et le rendu.
Lisez-les ensemble. Un input delay élevé avec loadState à dom-interactive ou dom-content-loaded signifie que l’utilisateur a appuyé pendant le démarrage de la page, souvent pendant l’hydratation ou des scripts tiers. Un processing élevé désigne le gestionnaire. Une presentation élevée désigne un DOM volumineux ou une mise en page forcée. Dans Chromium, longestScript, issu de la Long Animation Frames API, nomme le script qui a tourné le plus longtemps.
Pour reproduire, ouvrez le panneau Performance, appliquez le bridage CPU suggéré et répétez l’interaction sur le target remonté par le RUM. Toute tâche de plus de 50 millisecondes est une tâche longue, et une interaction mise en file derrière elle hérite de son temps restant.
Correctifs INP : moins de travail par interaction
Afficher la réponse d’abord
Faites le minimum qui donne un retour visible, laissez le navigateur peindre, puis continuez. L’analytique et l’enregistrement automatique ont rarement besoin de finir avant l’image suivante. Le guide des tâches longues propose cette fonction utilitaire :
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
filterButton.addEventListener('click', async () => {
filterButton.setAttribute('aria-busy', 'true');
await yieldToMain();
const rows = filterRows(allRows, currentQuery());
renderRows(rows);
filterButton.removeAttribute('aria-busy');
await yieldToMain();
sendAnalytics('filter', rows.length);
});
async function processInChunks(items, handleItem, budgetMs = 40) {
let lastYield = performance.now();
for (const item of items) {
handleItem(item);
if (performance.now() - lastYield > budgetMs) {
await yieldToMain();
lastYield = performance.now();
}
}
}
Céder la main avec scheduler.yield quand il existe
scheduler.yield() reprend votre code sous forme de continuation prioritaire, avant les autres tâches de priorité similaire en file. MDN le classe en disponibilité limitée : Chrome et Edge 129+, Firefox 142+, pas Safari. La détection de fonctionnalité avec un repli sur setTimeout() est donc obligatoire. Le repli découpe quand même la tâche, mais perd la priorité. web.dev ne recommande plus isInputPending() et conseille de céder la main dans tous les cas. La fonction de découpage maintient chaque tâche sous 40 millisecondes, si bien qu’un appui au milieu de la boucle n’attend qu’une courte tâche.
Hydratation et îlots
L’hydratation de toute la page exécute l’arbre de composants entier pendant le chargement, contenu statique compris. Les interactions dans cette fenêtre subissent de longs délais d’entrée. Rendez le contenu statique en HTML, n’hydratez que les composants interactifs et retardez-les jusqu’à l’inactivité du navigateur ou leur apparition à l’écran. Dans Astro, des directives choisissent le moment où chaque îlot s’hydrate :
---
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import PriceChart from '../components/PriceChart.jsx';
---
<SearchBox client:idle={{ timeout: 2000 }} />
<Comments client:visible={{ rootMargin: "200px" }} />
<PriceChart client:media="(min-width: 960px)" />
Les composants sans directive n’envoient aucun JavaScript. Les React Server Components et l’hydratation partielle d’autres frameworks visent le même résultat, mais il faut le mesurer plutôt que le supposer. Auditez aussi les scripts tiers : supprimez les balises inutilisées, chargez le reste pendant l’inactivité et surveillez leurs URL dans longestScript.
Presentation delay
Le coût du rendu croît avec la taille du DOM. Gardez un DOM léger, utilisez content-visibility: auto pour les longues sections hors écran, et ne lisez pas offsetHeight juste après avoir écrit des styles, ce qui force une mise en page synchrone. Déplacez l’analyse ou le tri de gros volumes de données dans un Web Worker.
CLS : réserver la place et stabiliser les polices
Un décalage de mise en page vaut la fraction d’impact multipliée par la fraction de distance. Les décalages espacés de moins d’une seconde forment une fenêtre de session d’au plus 5 secondes, et le CLS correspond à la plus grande fenêtre. Les décalages survenant dans les 500 millisecondes qui suivent un appui ou une frappe sont exclus. Le guide d’optimisation du CLS liste les causes habituelles :
- Médias sans dimensions : indiquez
widthetheightouaspect-ratiopour que la place soit réservée. - Publicités, intégrations et bannières : réservez l’emplacement avec
min-height, et n’insérez jamais de contenu tardif au-dessus de ce que l’utilisateur lit. - Polices web : utilisez
font-display: optional, ouswapavec une police de repli ajustée parsize-adjustet les surcharges ascent, descent et line-gap, et préchargez les polices critiques. - Animations : animez
transformetopacity, pastop,left,widthouheight. Les transformations composées ne comptent pas dans le CLS. - Navigation arrière et avant : une restauration depuis le bfcache affiche une page déjà chargée sans décalage, gardez donc vos pages éligibles.
.ad-slot {
min-height: 250px;
}
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 104%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
.toast {
transform: translateY(100%);
transition: transform 200ms ease-out;
}
.toast.is-visible {
transform: translateY(0);
}
Les pourcentages de surcharge sont des valeurs d’exemple. Calculez-les à partir des métriques de votre vraie police et comparez les deux polices dans le navigateur.
Par quoi commencer
Commencez par la métrique qui échoue sur les groupes d’URL les plus fréquentés, sur mobile sauf si le desktop est votre audience principale. Dans cette métrique, travaillez la sous-partie ou la phase qui pèse le plus au p75. Livrez d’abord les correctifs bon marché : fetchpriority, le retrait du chargement différé sur le hero et les dimensions d’images prennent quelques minutes, alors que les îlots ou le rendu serveur demandent une migration planifiée.
| Signal terrain | Cause probable | Premier correctif | Vérifier avec |
|---|---|---|---|
| LCP, TTFB dominant | HTML non mis en cache, redirections | Cache en périphérie, pas de chaînes de redirections | Mesures curl |
| LCP, délai de chargement dominant | Découverte tardive, hero différé, rendu client | <img> dans le HTML, fetchpriority |
Cascade réseau |
| LCP, durée de chargement dominante | Image trop lourde, format ancien | AVIF ou WebP, srcset, CDN |
lcpResourceEntry |
| LCP, délai de rendu dominant | CSS, scripts ou polices bloquants | CSS critique, scripts différés | Trace Performance |
| INP, délai d’entrée pendant le chargement | Hydratation, balises tierces | Îlots, hydratation retardée | loadState |
| INP, traitement long | Gestionnaire lourd | Afficher d’abord, yield, worker | longestScript |
| INP, présentation longue | DOM volumineux, mise en page forcée | DOM plus petit, content-visibility |
Tableau des interactions |
| CLS pendant le chargement | Médias sans taille, changement de police | Dimensions, police de repli ajustée | largestShiftTarget |
| CLS après le chargement | Contenu tardif, animations de mise en page | Emplacements réservés, transform |
Onglet des décalages |
Vérifier le correctif et éviter les régressions
Confirmez chaque changement deux fois. En laboratoire, comparez les traces Performance avant et après avec un bridage fondé sur le terrain, et vérifiez que la sous-partie visée a diminué. Sur le terrain, suivez le p75 de votre RUM pour le modèle concerné, qui réagit en quelques jours, et laissez CrUX suivre sur 28 jours. Lancez ensuite « Commencer le suivi » dans Search Console pour démarrer sa validation de 28 jours.
Gardez un budget Lighthouse en CI pour attraper les nouveaux scripts bloquants, les images sans dimensions ou les sauts de Total Blocking Time. Il ne voit pas les vraies interactions, alors ajoutez aussi une alerte sur l’INP terrain et annotez les déploiements dans le tableau de bord RUM.
Checklist
- Lisez les données terrain de Search Console et PageSpeed Insights pour le mobile et le desktop.
- Notez quelle métrique échoue au p75 sur vos groupes d’URL les plus fréquentés.
- Ajoutez le build d’attribution de
web-vitalset envoyez les beacons survisibilitychange. - Agrégez le RUM par modèle, type d’appareil et
target. - Identifiez la sous-partie LCP dominante avant de choisir un correctif.
- Placez le hero dans le HTML avec
fetchpriority="high", sans chargement différé et avec unsrcsetcorrect. - Mettez le HTML en cache en périphérie et gardez le TTFB à 0,8 seconde ou moins.
- Découpez les gestionnaires longs avec
scheduler.yield()et un replisetTimeout(). - N’hydratez que les îlots interactifs et auditez les scripts tiers.
- Réservez la place des médias, publicités et bannières, et ajustez les polices de repli.
- Vérifiez chaque correctif dans une trace bridée, puis dans le RUM, puis dans CrUX.