Core Web Vitals en 2026 : ce qui fait vraiment la différence
Oublie la théorie — voici ce que j'ai réellement fait pour obtenir un LCP sous 2,5s, un CLS à zéro et un INP sous 200ms sur un vrai site Next.js en production. Des techniques concrètes, pas des conseils vagues.
J'ai passé la majeure partie de deux semaines à rendre ce site rapide. Pas « rapide dans un audit Lighthouse sur mon MacBook M3 ». Réellement rapide. Rapide sur un téléphone Android à 150 € avec une connexion 4G instable dans un tunnel de métro. Rapide là où ça compte.
Le résultat : un LCP sous 1,8s, un CLS à 0,00, un INP sous 120ms. Les trois en vert dans les données CrUX, pas juste les scores de labo. Et j'ai appris quelque chose au passage — la plupart des conseils de performance sur internet sont soit obsolètes, soit vagues, soit les deux.
« Optimise tes images » n'est pas un conseil. « Utilise le lazy loading » sans contexte est dangereux. « Minimise le JavaScript » est évident mais ne te dit rien sur quoi couper.
Voici ce que j'ai réellement fait, dans l'ordre qui comptait.
Pourquoi les Core Web Vitals comptent encore en 2026#
Soyons directs : Google utilise les Core Web Vitals comme signal de classement. Pas le seul signal, et même pas le plus important. La pertinence du contenu, les backlinks et l'autorité du domaine dominent toujours. Mais en marge — là où deux pages ont un contenu et une autorité comparables — la performance est un facteur de départage. Et sur internet, des millions de pages vivent dans ces marges.
Mais oublie le SEO une seconde. La vraie raison de se soucier de la performance, ce sont les utilisateurs. Les données n'ont pas beaucoup changé ces cinq dernières années :
- 53 % des visites mobiles sont abandonnées si une page met plus de 3 secondes à charger (recherche Google/SOASTA, toujours valide)
- Chaque 100ms de latence coûte environ 1 % de conversions (constat original d'Amazon, validé à de nombreuses reprises)
- Les utilisateurs qui subissent des décalages de mise en page sont nettement moins susceptibles de finaliser un achat ou de remplir un formulaire
Les Core Web Vitals en 2026 consistent en trois métriques :
| Métrique | Ce qu'elle mesure | Bon | À améliorer | Mauvais |
|---|---|---|---|---|
| LCP | Performance de chargement | ≤ 2,5s | 2,5s – 4,0s | > 4,0s |
| CLS | Stabilité visuelle | ≤ 0,1 | 0,1 – 0,25 | > 0,25 |
| INP | Réactivité | ≤ 200ms | 200ms – 500ms | > 500ms |
Ces seuils n'ont pas changé depuis qu'INP a remplacé FID en mars 2024. Mais les techniques pour les atteindre ont évolué, surtout dans l'écosystème React/Next.js.
LCP : celui qui compte le plus#
Le Largest Contentful Paint mesure le moment où le plus grand élément visible dans le viewport finit de se rendre. Pour la plupart des pages, c'est une image hero, un titre, ou un gros bloc de texte.
Étape 1 : trouver ton vrai élément LCP#
Avant d'optimiser quoi que ce soit, tu dois savoir quel est ton élément LCP. Les gens supposent que c'est leur image hero. Parfois c'est une police web qui rend le <h1>. Parfois c'est une image de fond appliquée via CSS. Parfois c'est un poster frame de <video>.
Ouvre Chrome DevTools, va dans le panneau Performance, enregistre un chargement de page, et cherche le marqueur « LCP ». Il te dit exactement quel élément a déclenché le LCP.
Tu peux aussi utiliser la bibliothèque web-vitals pour le logger de manière programmatique :
import { onLCP } from "web-vitals";
onLCP((metric) => {
console.log("LCP element:", metric.entries[0]?.element);
console.log("LCP value:", metric.value, "ms");
});Sur ce site, l'élément LCP s'est avéré être l'image hero sur la page d'accueil et le premier paragraphe de texte sur les articles de blog. Deux éléments différents, deux stratégies d'optimisation différentes.
Étape 2 : précharger la ressource LCP#
Si ton élément LCP est une image, la chose la plus impactante que tu puisses faire est de la précharger. Par défaut, le navigateur découvre les images quand il parse le HTML, ce qui signifie que la requête de l'image ne démarre pas avant que le HTML soit téléchargé, parsé, et que la balise <img> soit atteinte. Le préchargement déplace cette découverte au tout début.
Dans Next.js, tu peux ajouter un lien de préchargement dans ton layout ou ta page :
import Head from "next/head";
export default function HeroSection() {
return (
<>
<Head>
<link
rel="preload"
as="image"
href="/images/hero-optimized.webp"
type="image/webp"
fetchPriority="high"
/>
</Head>
<section className="relative h-[600px]">
<img
src="/images/hero-optimized.webp"
alt="Hero banner"
width={1200}
height={600}
fetchPriority="high"
decoding="sync"
/>
</section>
</>
);
}Note le fetchPriority="high". C'est la plus récente Fetch Priority API, et c'est un véritable game changer. Sans elle, le navigateur utilise ses propres heuristiques pour prioriser les ressources — et ces heuristiques se trompent souvent, surtout quand tu as plusieurs images au-dessus de la ligne de flottaison.
Sur ce site, ajouter fetchPriority="high" à l'image LCP a fait baisser le LCP d'environ 400ms. C'est le plus gros gain que j'aie jamais obtenu d'un changement d'une seule ligne.
Étape 3 : éliminer les ressources bloquant le rendu#
Le CSS bloque le rendu. Tout le CSS. Si tu as une feuille de style de 200 Ko chargée via <link rel="stylesheet">, le navigateur n'affichera rien tant qu'elle n'est pas entièrement téléchargée et parsée.
Le correctif est triple :
-
Inliner le CSS critique — Extrais le CSS nécessaire pour le contenu au-dessus de la ligne de flottaison et inline-le dans une balise
<style>dans le<head>. Next.js fait ça automatiquement quand tu utilises les CSS Modules ou Tailwind avec un purge correct. -
Différer le CSS non critique — Si tu as des feuilles de styles pour du contenu sous la ligne de flottaison (une bibliothèque d'animation de footer, un composant de graphique), charge-les de manière asynchrone :
<link
rel="preload"
href="/styles/charts.css"
as="style"
onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
<link rel="stylesheet" href="/styles/charts.css" />
</noscript>- Supprimer le CSS inutilisé — Tailwind CSS v4 fait ça automatiquement avec son moteur JIT. Mais si tu importes des bibliothèques CSS tierces, audite-les. J'en ai trouvé une qui importait 180 Ko de CSS pour un seul composant tooltip. Remplacée par un composant custom de 20 lignes, 170 Ko économisés.
Étape 4 : temps de réponse du serveur (TTFB)#
Le LCP ne peut pas être rapide si le TTFB est lent. Si ton serveur met 800ms à répondre, ton LCP sera au minimum de 800ms + tout le reste.
Sur ce site (Node.js + PM2 + Nginx sur un VPS), j'ai mesuré le TTFB à environ 180ms sur un hit froid. Voici ce que j'ai fait pour le maintenir là :
- ISR (Incremental Static Regeneration) pour les articles de blog — les pages sont pré-rendues au build et revalidées périodiquement. La première visite sert un fichier statique directement depuis le cache du reverse proxy Nginx.
- Headers de cache edge —
Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400sur les pages statiques. - Compression Gzip/Brotli dans Nginx — réduit la taille de transfert de 60-80 %.
# extrait de nginx.conf
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
gzip_comp_level 6;
# Brotli (si le module ngx_brotli est installé)
brotli on;
brotli_types text/plain text/css application/json application/javascript text/xml;
brotli_comp_level 6;Mon avant/après sur le LCP :
- Avant optimisation : 3,8s (75e percentile, CrUX)
- Après preload + fetchPriority + compression : 1,8s
- Amélioration totale : réduction de 53 %
CLS : la mort par mille décalages#
Le Cumulative Layout Shift mesure combien le contenu visible bouge pendant le chargement de la page. Un CLS de 0 signifie que rien n'a bougé. Un CLS au-dessus de 0,1 signifie que quelque chose agace visuellement tes utilisateurs.
Le CLS est la métrique que la plupart des développeurs sous-estiment. Tu ne le remarques pas sur ta machine de développement rapide avec tout en cache. Tes utilisateurs le remarquent sur leurs téléphones, sur des connexions lentes, où les polices chargent tard et les images apparaissent une par une.
Les coupables habituels#
1. Images sans dimensions explicites
C'est la cause de CLS la plus courante. Quand une image charge, elle pousse le contenu en dessous vers le bas. Le correctif est embarrassamment simple : toujours spécifier width et height sur les balises <img>.
// MAUVAIS — provoque un décalage de mise en page
<img src="/photo.jpg" alt="Photo d'équipe" />
// BON — le navigateur réserve l'espace avant le chargement de l'image
<img src="/photo.jpg" alt="Photo d'équipe" width={800} height={450} />Si tu utilises <Image> de Next.js, il gère ça automatiquement tant que tu fournis les dimensions ou que tu utilises fill avec un conteneur parent dimensionné.
Mais voici le piège : si tu utilises le mode fill, le conteneur parent doit avoir des dimensions explicites, sinon l'image causera du CLS :
// MAUVAIS — le parent n'a pas de dimensions
<div className="relative">
<Image src="/photo.jpg" alt="Équipe" fill />
</div>
// BON — le parent a un ratio d'aspect explicite
<div className="relative aspect-video w-full">
<Image src="/photo.jpg" alt="Équipe" fill sizes="100vw" />
</div>2. Les polices web causant du FOUT/FOIT
Quand une police custom charge, le texte rendu avec la police de fallback est re-rendu avec la police custom. Si les deux polices ont des métriques différentes (c'est presque toujours le cas), tout bouge.
Le correctif moderne est font-display: swap combiné avec des polices de fallback ajustées en taille :
// Utilisation de next/font — la meilleure approche pour Next.js
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
// next/font génère automatiquement des polices de fallback ajustées en taille
// Cela élimine le CLS dû au swap de polices
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="fr" className={inter.className}>
<body>{children}</body>
</html>
);
}next/font est véritablement l'une des meilleures choses de Next.js. Il télécharge les polices au build, les auto-héberge (pas de requête Google Fonts au runtime), et génère des polices de fallback ajustées en taille pour que le swap de la police de fallback à la police custom ne cause aucun décalage de mise en page. J'ai mesuré le CLS dû aux polices à 0,00 après être passé à next/font. Avant, avec un <link> Google Fonts standard, c'était entre 0,04 et 0,08.
3. L'injection de contenu dynamique
Pubs, bannières de cookies, barres de notification — tout ce qui est injecté dans le DOM après le rendu initial cause du CLS si ça pousse le contenu vers le bas.
Le correctif : réserver de l'espace pour le contenu dynamique avant qu'il charge.
// Bannière de cookies — réserver l'espace en bas
function CookieBanner() {
const [accepted, setAccepted] = useState(false);
if (accepted) return null;
return (
// Le positionnement fixe ne cause pas de CLS car il
// n'affecte pas le flux du document
<div className="fixed bottom-0 left-0 right-0 z-50 bg-gray-900 p-4">
<p>Nous utilisons des cookies. Tu connais la chanson.</p>
<button onClick={() => setAccepted(true)}>Accepter</button>
</div>
);
}Utiliser position: fixed ou position: absolute pour les éléments dynamiques est une approche sans CLS car ces éléments n'affectent pas le flux normal du document.
4. L'astuce CSS aspect-ratio
Pour les conteneurs responsifs où tu connais le ratio d'aspect mais pas les dimensions exactes, utilise la propriété CSS aspect-ratio :
// Intégration vidéo sans CLS
function VideoEmbed({ src }: { src: string }) {
return (
<div className="w-full aspect-video bg-gray-900 rounded-lg overflow-hidden">
<iframe
src={src}
className="w-full h-full"
title="Vidéo intégrée"
allow="accelerometer; autoplay; clipboard-write; encrypted-media"
allowFullScreen
/>
</div>
);
}L'utilitaire aspect-video (qui correspond à aspect-ratio: 16/9) réserve exactement la bonne quantité d'espace. Aucun décalage quand l'iframe charge.
5. Les skeleton screens
Pour le contenu qui charge de manière asynchrone (données d'API, composants dynamiques), affiche un skeleton qui correspond aux dimensions attendues :
function PostCardSkeleton() {
return (
<div className="animate-pulse rounded-lg border p-4">
<div className="h-48 w-full rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-6 w-3/4 rounded bg-gray-200" />
<div className="h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-5/6 rounded bg-gray-200" />
</div>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = usePosts();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts?.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}L'essentiel est que PostCardSkeleton et PostCard doivent avoir les mêmes dimensions. Si le skeleton fait 200px de haut et la carte réelle 280px de haut, tu auras toujours un décalage.
Mes résultats CLS :
- Avant : 0,12 (le swap de police seul faisait 0,06)
- Après : 0,00 — littéralement zéro, sur des milliers de chargements de page dans les données CrUX
INP : le petit nouveau qui mord#
Interaction to Next Paint a remplacé First Input Delay en mars 2024, et c'est une métrique fondamentalement plus difficile à optimiser. FID ne mesurait que le délai avant le traitement de la première interaction. INP mesure chaque interaction tout au long du cycle de vie de la page et rapporte la pire (au 75e percentile).
Cela signifie qu'une page peut avoir un excellent FID mais un INP terrible si, par exemple, cliquer sur un menu déroulant 30 secondes après le chargement déclenche un reflow de 500ms.
Ce qui cause un INP élevé#
- Les longues tâches sur le thread principal — Toute exécution JavaScript qui prend plus de 50ms bloque le thread principal. Les interactions utilisateur qui se produisent pendant une longue tâche doivent attendre.
- Les re-rendus coûteux en React — Une mise à jour d'état qui cause le re-rendu de 200 composants prend du temps. L'utilisateur clique sur quelque chose, React fait la réconciliation, et le paint n'arrive pas avant 300ms.
- Le layout thrashing — Lire des propriétés de layout (comme
offsetHeight) puis les écrire (comme modifierstyle.height) dans une boucle force le navigateur à recalculer le layout de manière synchrone. - Un DOM volumineux — Plus de nœuds DOM signifie un recalcul de styles et de layout plus lent. Un DOM avec 5 000 nœuds est nettement plus lent qu'un DOM avec 500.
Découper les longues tâches avec scheduler.yield()#
La technique la plus impactante pour l'INP est de découper les longues tâches afin que le navigateur puisse traiter les interactions utilisateur entre les morceaux :
async function processLargeDataset(items: DataItem[]) {
const results: ProcessedItem[] = [];
for (let i = 0; i < items.length; i++) {
results.push(expensiveTransform(items[i]));
// Toutes les 10 itérations, on cède le contrôle au navigateur
// Cela permet aux interactions utilisateur en attente d'être traitées
if (i % 10 === 0 && "scheduler" in globalThis) {
await scheduler.yield();
}
}
return results;
}scheduler.yield() est disponible dans Chrome 129+ (septembre 2024) et c'est la manière recommandée de céder le contrôle au thread principal. Pour les navigateurs qui ne le supportent pas, tu peux recourir à un wrapper setTimeout(0) :
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in scheduler) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}useTransition pour les mises à jour non urgentes#
React 18+ nous donne useTransition, qui dit à React que certaines mises à jour d'état ne sont pas urgentes et peuvent être interrompues par du travail plus important (comme répondre aux saisies utilisateur) :
import { useState, useTransition } from "react";
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
// Cette mise à jour est urgente — l'input doit refléter la frappe immédiatement
setQuery(value);
// Cette mise à jour N'EST PAS urgente — filtrer 10 000 éléments peut attendre
startTransition(() => {
const filtered = items.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
}
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Rechercher..."
className="w-full rounded border px-4 py-2"
/>
{isPending && (
<p className="mt-2 text-sm text-gray-500">Filtrage en cours...</p>
)}
<ul className="mt-4 space-y-2">
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}Sans startTransition, taper dans le champ de recherche serait saccadé parce que React essaierait de filtrer 10 000 éléments de manière synchrone avant de mettre à jour le DOM. Avec startTransition, l'input se met à jour immédiatement, et le filtrage se fait en arrière-plan.
J'ai mesuré l'INP sur une page d'outil qui avait un gestionnaire d'input complexe. Avant useTransition : 380ms d'INP. Après : 90ms d'INP. C'est une amélioration de 76 % grâce à un changement d'API React.
Debouncing des gestionnaires d'input#
Pour les gestionnaires qui déclenchent des opérations coûteuses (appels API, calculs lourds), applique un debounce :
import { useCallback, useRef } from "react";
function useDebounce<T extends (...args: unknown[]) => void>(
fn: T,
delay: number
): T {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
((...args: unknown[]) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => fn(...args), delay);
}) as T,
[fn, delay]
);
}
// Utilisation
function LiveSearch() {
const [results, setResults] = useState<SearchResult[]>([]);
const search = useDebounce(async (query: string) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data.results);
}, 300);
return (
<input
type="text"
onChange={(e) => search(e.target.value)}
placeholder="Rechercher..."
/>
);
}300ms, c'est ma valeur de debounce par défaut. C'est assez court pour que les utilisateurs ne remarquent pas le délai, et assez long pour éviter de déclencher une requête à chaque frappe.
Web Workers pour les calculs lourds#
Si tu as des calculs véritablement lourds (parsing de gros JSON, manipulation d'images, calculs complexes), déporte-les hors du thread principal :
// worker.ts
self.addEventListener("message", (event) => {
const { data, operation } = event.data;
switch (operation) {
case "sort": {
// Ça pourrait prendre 500ms pour de gros jeux de données
const sorted = data.sort((a: number, b: number) => a - b);
self.postMessage({ result: sorted });
break;
}
case "filter": {
const filtered = data.filter((item: DataItem) =>
complexFilterLogic(item)
);
self.postMessage({ result: filtered });
break;
}
}
});// useWorker.ts
import { useEffect, useRef, useCallback } from "react";
function useWorker() {
const workerRef = useRef<Worker>();
useEffect(() => {
workerRef.current = new Worker(
new URL("../workers/worker.ts", import.meta.url)
);
return () => workerRef.current?.terminate();
}, []);
const process = useCallback(
(operation: string, data: unknown): Promise<unknown> => {
return new Promise((resolve) => {
if (!workerRef.current) return;
workerRef.current.onmessage = (event) => {
resolve(event.data.result);
};
workerRef.current.postMessage({ operation, data });
});
},
[]
);
return { process };
}Les Web Workers fonctionnent sur un thread séparé, donc même un calcul de 2 secondes n'affectera pas du tout l'INP. Le thread principal reste libre pour gérer les interactions utilisateur.
Mes résultats INP :
- Avant : 340ms (la pire interaction était un outil de test regex avec un traitement d'input complexe)
- Après useTransition + debouncing : 110ms
- Amélioration : réduction de 68 %
Les gains spécifiques à Next.js#
Si tu es sur Next.js (13+ avec App Router), tu as accès à des primitives de performance puissantes que la plupart des développeurs n'exploitent pas pleinement.
next/image — mais configuré correctement#
next/image est super, mais la configuration par défaut laisse de la performance sur la table :
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96, 128, 256],
minimumCacheTTL: 60 * 60 * 24 * 365, // 1 an
},
};
export default nextConfig;Paramètres clés :
formats: ["image/avif", "image/webp"]— L'AVIF est 20 à 50 % plus petit que le WebP. L'ordre compte : Next.js essaie l'AVIF en premier, revient au WebP, puis au format original.minimumCacheTTL— Par défaut, c'est 60 secondes. Pour un blog, les images ne changent pas. Cache-les pendant un an.deviceSizesetimageSizes— Les valeurs par défaut incluent 3840px. Sauf si tu sers des images 4K, réduis cette liste. Chaque taille génère une image cachée séparée, et les tailles inutilisées gaspillent de l'espace disque et du temps de build.
Et utilise toujours la prop sizes pour dire au navigateur quelle taille l'image sera à l'affichage :
// Image hero pleine largeur
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
sizes="100vw"
priority // Image LCP — ne pas la lazy-loader !
/>
// Image de carte dans une grille responsive
<Image
src="/card.jpg"
alt="Carte"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>Sans sizes, le navigateur pourrait télécharger une image de 1200px pour un emplacement de 300px. C'est du gaspillage d'octets et de temps.
La prop priority sur l'image LCP est cruciale. Elle désactive le lazy loading et ajoute automatiquement fetchPriority="high". Si ton élément LCP est un next/image, ajoute simplement priority et tu es déjà presque arrivé.
next/font — polices à zéro décalage de mise en page#
J'en ai parlé dans la section CLS, mais ça mérite d'être souligné. next/font est la seule solution de chargement de polices que j'ai vue qui atteint systématiquement zéro CLS :
import { Inter, JetBrains_Mono } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="fr"
className={`${inter.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}Deux polices, zéro CLS, zéro requête externe au runtime. Les polices sont téléchargées au build et servies depuis ton propre domaine.
Streaming avec Suspense#
C'est là que Next.js devient vraiment intéressant pour la performance. Avec le App Router, tu peux streamer des parties de la page au navigateur au fur et à mesure qu'elles deviennent prêtes :
import { Suspense } from "react";
import { PostList } from "@/components/blog/PostList";
import { Sidebar } from "@/components/blog/Sidebar";
import { PostListSkeleton } from "@/components/blog/PostListSkeleton";
import { SidebarSkeleton } from "@/components/blog/SidebarSkeleton";
export default function BlogPage() {
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="lg:col-span-2">
{/* Ceci charge vite — on le streame immédiatement */}
<h1 className="text-4xl font-bold">Blog</h1>
{/* Ceci nécessite une requête en base — on le streame quand c'est prêt */}
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
<aside>
{/* La sidebar peut charger indépendamment */}
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
</div>
);
}Le navigateur reçoit le shell (titre, navigation, layout) immédiatement. La liste d'articles et la sidebar se chargent au fur et à mesure que leurs données deviennent disponibles. L'utilisateur voit un chargement initial rapide, et le contenu se remplit progressivement.
C'est particulièrement puissant pour le LCP. Si ton élément LCP est le titre (pas la liste d'articles), il se rend immédiatement, peu importe le temps que prend la requête en base de données.
Configuration par segment de route#
Next.js te permet de configurer le cache et la revalidation au niveau du segment de route :
// app/blog/page.tsx
// Revalider cette page toutes les heures
export const revalidate = 3600;
// app/tools/[slug]/page.tsx
// Ces pages d'outils sont entièrement statiques — générées au build
export const dynamic = "force-static";
// app/api/search/route.ts
// Route API — jamais de cache
export const dynamic = "force-dynamic";Sur ce site, les articles de blog utilisent revalidate = 3600 (1 heure). Les pages d'outils utilisent force-static parce que leur contenu ne change jamais entre les déploiements. L'API de recherche utilise force-dynamic parce que chaque requête est unique.
Le résultat : la plupart des pages sont servies depuis le cache statique, le TTFB est sous 50ms pour les pages cachées, et le serveur n'a quasiment rien à faire.
Outils de mesure : fais confiance aux données, pas à tes yeux#
Ta perception de la performance n'est pas fiable. Ta machine de développement a 32 Go de RAM, un SSD NVMe et une connexion gigabit. Tes utilisateurs, non.
La pile de mesure que j'utilise#
1. Panneau Performance de Chrome DevTools
L'outil le plus détaillé disponible. Enregistre un chargement de page, regarde le flamegraph, identifie les longues tâches, trouve les ressources bloquant le rendu. C'est là que je passe la plupart de mon temps de débogage.
Points clés à surveiller :
- Les coins rouges sur les tâches = longues tâches (>50ms)
- Les événements Layout/Paint déclenchés par JavaScript
- Les gros blocs « Evaluate Script » (trop de JavaScript)
- Le waterfall réseau montrant des ressources découvertes tardivement
2. Lighthouse
Bon pour une vérification rapide, mais n'optimise pas pour les scores Lighthouse. Lighthouse fonctionne dans un environnement simulé avec throttling qui ne correspond pas parfaitement aux conditions du monde réel. J'ai vu des pages scorer 98 dans Lighthouse et avoir un LCP de 4s sur le terrain.
Utilise Lighthouse pour une orientation directionnelle, pas comme un tableau de scores.
3. PageSpeed Insights
L'outil le plus important pour les sites en production car il affiche les vraies données CrUX — des mesures réelles provenant de vrais utilisateurs Chrome sur les 28 derniers jours. Les données de labo te disent ce qui pourrait se passer. Les données CrUX te disent ce qui se passe réellement.
4. La bibliothèque web-vitals
Ajoute ceci à ton site en production pour collecter des métriques d'utilisateurs réels :
// components/analytics/WebVitals.tsx
"use client";
import { useEffect } from "react";
import { onCLS, onINP, onLCP, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
function sendToAnalytics(metric: Metric) {
// Envoyer à ton endpoint d'analytics
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Utilise sendBeacon pour ne pas bloquer le déchargement de la page
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", {
body,
method: "POST",
keepalive: true,
});
}
}
export function WebVitals() {
useEffect(() => {
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}, []);
return null;
}Ça te donne tes propres données similaires à CrUX, mais avec plus de détails. Tu peux segmenter par page, type d'appareil, vitesse de connexion, région géographique — tout ce dont tu as besoin.
5. Chrome User Experience Report (CrUX)
Le dataset CrUX BigQuery est gratuit et contient des données glissantes sur 28 jours pour des millions d'origines. Si ton site a suffisamment de trafic, tu peux requêter tes propres données :
SELECT
origin,
p75_lcp,
p75_cls,
p75_inp,
form_factor
FROM
`chrome-ux-report.materialized.metrics_summary`
WHERE
origin = 'https://tonsite.com'
AND yyyymm = 202603La liste noire du waterfall#
Les scripts tiers sont le tueur de performance numéro un sur la plupart des sites web. Voici ce que j'ai trouvé et ce que j'ai fait.
Google Tag Manager (GTM)#
GTM lui-même fait environ 80 Ko. Mais GTM charge d'autres scripts — analytics, pixels marketing, outils de tests A/B. J'ai vu des configurations GTM qui chargent 15 scripts supplémentaires totalisant 2 Mo.
Mon approche : ne pas utiliser GTM en production. Charge les scripts analytics directement, diffère tout, et utilise loading="lazy" pour les scripts qui peuvent attendre :
// Au lieu que GTM charge tout
// Ne charge que ce dont tu as besoin, quand tu en as besoin
export function AnalyticsScript() {
return (
<script
defer
src="https://analytics.example.com/script.js"
data-website-id="your-id"
/>
);
}Si tu dois absolument utiliser GTM, charge-le après que la page soit interactive :
"use client";
import { useEffect } from "react";
export function DeferredGTM({ containerId }: { containerId: string }) {
useEffect(() => {
// Attendre après le chargement de la page pour injecter GTM
const timer = setTimeout(() => {
const script = document.createElement("script");
script.src = `https://www.googletagmanager.com/gtm.js?id=${containerId}`;
script.async = true;
document.head.appendChild(script);
}, 3000); // délai de 3 secondes
return () => clearTimeout(timer);
}, [containerId]);
return null;
}Oui, tu perdras les données des utilisateurs qui rebondissent dans les 3 premières secondes. D'expérience, c'est un compromis qui en vaut la peine. Ces utilisateurs ne convertissaient pas de toute façon.
Les widgets de chat#
Les widgets de chat en direct (Intercom, Drift, Crisp) sont parmi les pires délinquants. Intercom seul charge plus de 400 Ko de JavaScript. Sur une page où 2 % des utilisateurs cliquent réellement sur le bouton de chat, c'est 400 Ko de JavaScript pour 98 % des utilisateurs.
Ma solution : charger le widget à l'interaction.
"use client";
import { useState } from "react";
export function ChatButton() {
const [loaded, setLoaded] = useState(false);
function loadChat() {
if (loaded) return;
// Charger le script du widget de chat seulement quand l'utilisateur clique
const script = document.createElement("script");
script.src = "https://chat-widget.example.com/widget.js";
script.onload = () => {
// Initialiser le widget après le chargement du script
window.ChatWidget?.open();
};
document.head.appendChild(script);
setLoaded(true);
}
return (
<button
onClick={loadChat}
className="fixed bottom-4 right-4 rounded-full bg-blue-600 p-4 text-white shadow-lg"
aria-label="Ouvrir le chat"
>
{loaded ? "Chargement..." : "Discuter avec nous"}
</button>
);
}Le JavaScript inutilisé#
Lance Coverage dans Chrome DevTools (Ctrl+Shift+P > « Show Coverage »). Ça te montre exactement combien de chaque script est réellement utilisé sur la page actuelle.
Sur un site Next.js typique, je trouve généralement :
- Des bibliothèques de composants chargées entièrement — Tu importes
Buttondepuis une bibliothèque UI, mais toute la bibliothèque est bundlée. Solution : utilise des bibliothèques tree-shakeable ou importe depuis les sous-chemins (import Button from "lib/Button"au lieu deimport { Button } from "lib"). - Des polyfills pour navigateurs modernes — Vérifie si tu expédies des polyfills pour
Promise,fetch, ouArray.prototype.includes. En 2026, tu n'en as plus besoin. - Des feature flags morts — Des chemins de code derrière des feature flags qui sont « on » depuis six mois. Supprime le flag et la branche morte.
J'utilise le bundle analyzer de Next.js pour trouver les chunks surdimensionnés :
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
// ta config
};
export default process.env.ANALYZE === "true"
? withBundleAnalyzer({ enabled: true })(nextConfig)
: nextConfig;ANALYZE=true npm run buildÇa ouvre une treemap visuelle de tes bundles. J'ai trouvé une bibliothèque de formatage de dates de 120 Ko que j'ai remplacée par le natif Intl.DateTimeFormat. J'ai trouvé un parser markdown de 90 Ko importé sur une page qui n'utilisait pas de markdown. Des petits gains qui s'accumulent.
Le CSS bloquant le rendu#
J'en ai parlé dans la section LCP, mais ça vaut la peine de le répéter tant c'est courant. Chaque <link rel="stylesheet"> dans le <head> bloque le rendu. Si tu as cinq feuilles de styles, le navigateur attend les cinq avant d'afficher quoi que ce soit.
Next.js avec Tailwind gère ça bien — le CSS est inliné et minimal. Mais si tu importes du CSS tiers, audite-le :
// MAUVAIS — charge le CSS de toute la bibliothèque sur chaque page
import "some-library/dist/styles.css";
// MIEUX — import dynamique, ne charge que sur les pages qui en ont besoin
const SomeComponent = dynamic(
() => import("some-library").then((mod) => {
// Le CSS est importé dans le composant dynamique
import("some-library/dist/styles.css");
return mod.SomeComponent;
}),
{ ssr: false }
);Une vraie histoire d'optimisation#
Laisse-moi te raconter l'optimisation réelle de la page d'outils de ce site. C'est une page avec plus de 15 outils interactifs, chacun avec son propre composant, et certains d'entre eux (comme le testeur de regex et le formateur JSON) sont gourmands en JavaScript.
Le point de départ#
Mesures initiales (données CrUX, mobile, 75e percentile) :
- LCP : 3,8s — Mauvais
- CLS : 0,12 — À améliorer
- INP : 340ms — Mauvais
Score Lighthouse : 62.
L'investigation#
Analyse du LCP : L'élément LCP était le titre de la page (<h1>), qui devrait se rendre instantanément. Mais il était retardé par :
- Un fichier CSS de 200 Ko d'une bibliothèque de composants (bloquant le rendu)
- Une police custom chargée via le CDN Google Fonts (FOIT de 800ms sur les connexions lentes)
- Un TTFB de 420ms parce que la page était rendue côté serveur à chaque requête sans cache
Analyse du CLS : Trois sources :
- Le swap de police depuis le fallback Google Fonts vers la police custom : 0,06
- Les cartes d'outils chargeant sans réservation de hauteur : 0,04
- Une bannière de cookies injectée en haut de la page, poussant tout vers le bas : 0,02
Analyse de l'INP : L'outil de test regex était le pire délinquant. Chaque frappe dans le champ regex déclenchait :
- Un re-rendu complet de tout le composant d'outil
- L'évaluation de la regex contre la chaîne de test
- La coloration syntaxique du pattern regex
Temps total par frappe : 280-400ms.
Les corrections#
Semaine 1 : LCP et CLS
-
Remplacement du CDN Google Fonts par
next/font. La police est maintenant auto-hébergée, chargée au build, avec un fallback ajusté en taille. CLS dû aux polices : 0,06 → 0,00 -
Suppression du CSS de la bibliothèque de composants. Réécriture des 3 composants que j'utilisais avec Tailwind. CSS total supprimé : 180 Ko. CSS bloquant le rendu : éliminé
-
Ajout de
revalidate = 3600à la page d'outils et aux pages de détail. Le premier hit est rendu côté serveur, les suivants sont servis depuis le cache. TTFB : 420ms → 45ms (caché) -
Ajout de dimensions explicites à tous les composants de cartes d'outils et utilisation de
aspect-ratiopour les layouts responsifs. CLS dû aux cartes : 0,04 → 0,00 -
Déplacement de la bannière de cookies en
position: fixeden bas de l'écran. CLS dû à la bannière : 0,02 → 0,00
Semaine 2 : INP
- Encapsulation du calcul de résultats du testeur de regex dans
startTransition:
function RegexTester() {
const [pattern, setPattern] = useState("");
const [testString, setTestString] = useState("");
const [results, setResults] = useState<RegexResult[]>([]);
const [isPending, startTransition] = useTransition();
function handlePatternChange(value: string) {
setPattern(value); // Urgent : mettre à jour l'input
startTransition(() => {
// Non urgent : calculer les correspondances
try {
const regex = new RegExp(value, "g");
const matches = [...testString.matchAll(regex)];
setResults(
matches.map((m) => ({
match: m[0],
index: m.index ?? 0,
groups: m.groups,
}))
);
} catch {
setResults([]);
}
});
}
return (
<div>
<input
value={pattern}
onChange={(e) => handlePatternChange(e.target.value)}
className={isPending ? "opacity-70" : ""}
/>
{/* rendu des résultats */}
</div>
);
}INP du testeur de regex : 380ms → 85ms
-
Ajout du debouncing au gestionnaire d'input du formateur JSON (délai de 300ms). INP du formateur JSON : 260ms → 60ms
-
Déplacement du calcul du générateur de hash vers un Web Worker. Le hachage SHA-256 des grosses entrées se fait maintenant entièrement hors du thread principal. INP du générateur de hash : 200ms → 40ms
Les résultats#
Après deux semaines d'optimisation (données CrUX, mobile, 75e percentile) :
- LCP : 3,8s → 1,8s (amélioration de 53 %)
- CLS : 0,12 → 0,00 (amélioration de 100 %)
- INP : 340ms → 110ms (amélioration de 68 %)
Score Lighthouse : 62 → 97.
Les trois métriques solidement dans la zone « Bon ». La page donne une impression d'instantanéité sur mobile. Et le trafic de recherche organique a augmenté de 12 % le mois suivant les améliorations (même si je ne peux pas prouver la causalité — d'autres facteurs étaient en jeu).
La checklist#
Si tu ne retiens qu'une seule chose de cet article, voici la checklist que j'applique sur chaque projet :
LCP#
- Identifier l'élément LCP avec DevTools
- Ajouter
priority(oufetchPriority="high") à l'image LCP - Précharger les ressources LCP dans le
<head> - Éliminer le CSS bloquant le rendu
- Auto-héberger les polices avec
next/font - Activer la compression Brotli/Gzip
- Utiliser la génération statique ou ISR quand c'est possible
- Définir des headers de cache agressifs pour les assets statiques
CLS#
- Toutes les images ont des
widthetheightexplicites - Utiliser
next/fontavec des fallbacks ajustés en taille - Le contenu dynamique utilise
position: fixed/absoluteou un espace réservé - Les skeleton screens correspondent aux dimensions réelles des composants
- Pas d'injection de contenu en haut de page après le chargement
INP#
- Pas de longues tâches (>50ms) pendant les gestionnaires d'interaction
- Les mises à jour d'état non urgentes enveloppées dans
startTransition - Les gestionnaires d'input debouncés (300ms)
- Les calculs lourds déchargés vers des Web Workers
- La taille du DOM sous 1 500 nœuds dans la mesure du possible
Général#
- Les scripts tiers chargés après que la page soit interactive
- La taille des bundles analysée et tree-shakée
- Le CSS inutilisé supprimé
- Les images servies en format AVIF/WebP
- Le monitoring utilisateur réel en production (bibliothèque web-vitals)
Réflexions finales#
L'optimisation de la performance n'est pas une tâche ponctuelle. C'est une discipline. Chaque nouvelle fonctionnalité, chaque nouvelle dépendance, chaque nouveau script tiers est une régression potentielle. Les sites qui restent rapides sont ceux où quelqu'un surveille les métriques en continu, pas ceux où quelqu'un a fait un sprint d'optimisation ponctuel.
Mets en place un monitoring utilisateur réel. Configure des alertes quand les métriques régressent. Fais de la performance une partie intégrante de ton processus de revue de code. Quand quelqu'un ajoute une bibliothèque de 200 Ko, demande s'il existe une alternative de 5 Ko. Quand quelqu'un ajoute un calcul synchrone dans un gestionnaire d'événements, demande si ça peut être différé ou déplacé vers un worker.
Les techniques de cet article ne sont pas théoriques. C'est ce que j'ai réellement fait, sur ce site, avec de vrais chiffres pour le prouver. Tes résultats varieront — chaque site est différent, chaque audience est différente, chaque infrastructure est différente. Mais les principes sont universels : charge moins, charge plus intelligemment, ne bloque pas le thread principal.
Tes utilisateurs ne t'enverront pas un mot de remerciement pour un site rapide. Mais ils resteront. Ils reviendront. Et Google le remarquera.