Un e-commerce de 12 000 fiches produit sous Next.js affiche un LCP médian de 4.8s sur mobile d'après la Search Console. Résultat : 68 % des pages sont classées "Poor" dans le rapport Core Web Vitals, et le site perd progressivement des positions sur ses requêtes transactionnelles à fort volume. Le problème n'est pas un seul facteur — c'est une chaîne de latences cumulées qu'il faut décomposer méthodiquement.
Le Largest Contentful Paint mesure le temps nécessaire pour que le plus grand élément visible du viewport termine son rendu. Google considère un LCP inférieur à 2.5s comme "Good" et au-delà de 4s comme "Poor" (source : web.dev). Ce qui rend le diagnostic complexe, c'est que l'élément LCP varie d'une page à l'autre — image hero sur une landing, bloc de texte <h1> sur une page catégorie, <video> poster sur une homepage. Chaque type d'élément a ses propres vecteurs d'optimisation.
Décomposer le LCP en sous-phases : le seul vrai point de départ
Optimiser le LCP sans comprendre sa décomposition revient à deviner. Google a formalisé quatre sous-phases dans le calcul du LCP :
- Time to First Byte (TTFB) — temps entre la requête et le premier octet de la réponse HTML.
- Resource Load Delay — temps entre la réception du HTML et le début du chargement de la ressource LCP (image, font, etc.).
- Resource Load Duration — temps de téléchargement de la ressource elle-même.
- Element Render Delay — temps entre la fin du chargement de la ressource et son rendu effectif dans le viewport.
Sur le terrain, la répartition typique d'un LCP de 4.8s ressemble souvent à : TTFB 1.2s + Resource Load Delay 1.5s + Resource Load Duration 1.4s + Element Render Delay 0.7s. Attaquer uniquement la compression d'images ne résout que la troisième phase.
Mesurer chaque sous-phase avec l'API PerformanceObserver
Chrome DevTools (onglet Performance) affiche le LCP mais ne décompose pas toujours clairement chaque sous-phase. Pour obtenir des chiffres précis en conditions réelles (RUM), utilisez l'API native :
// Mesure des sous-phases LCP en Real User Monitoring
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
const navEntry = performance.getEntriesByType('navigation')[0];
const ttfb = navEntry.responseStart - navEntry.requestStart;
// Si l'élément LCP est une ressource (image, vidéo)
if (lastEntry.url) {
const resourceEntries = performance.getEntriesByType('resource');
const lcpResource = resourceEntries.find(r => r.name === lastEntry.url);
if (lcpResource) {
const resourceLoadDelay = lcpResource.startTime - navEntry.responseEnd;
const resourceLoadDuration = lcpResource.responseEnd - lcpResource.startTime;
const elementRenderDelay = lastEntry.startTime - lcpResource.responseEnd;
console.table({
ttfb: `${ttfb.toFixed(0)}ms`,
resourceLoadDelay: `${resourceLoadDelay.toFixed(0)}ms`,
resourceLoadDuration: `${resourceLoadDuration.toFixed(0)}ms`,
elementRenderDelay: `${elementRenderDelay.toFixed(0)}ms`,
totalLCP: `${lastEntry.startTime.toFixed(0)}ms`
});
}
} else {
// L'élément LCP est un nœud texte (h1, p, etc.)
const elementRenderDelay = lastEntry.startTime - navEntry.responseEnd;
console.table({
ttfb: `${ttfb.toFixed(0)}ms`,
elementRenderDelay: `${elementRenderDelay.toFixed(0)}ms`,
totalLCP: `${lastEntry.startTime.toFixed(0)}ms`
});
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
Déployez ce script en production pendant 48h et agrégez les données. Vous saurez exactement quelle phase attaquer en priorité. C'est la différence entre optimiser au hasard et résoudre le vrai problème.
Outils complémentaires de diagnostic
- Chrome DevTools → Performance panel : enregistrez un profil sur une connexion throttled (Slow 4G). Le waterfall révèle visuellement les gaps entre la fin du parsing HTML et le début du fetch de l'image LCP.
- WebPageTest : le filmstrip frame-by-frame combiné au waterfall est le gold standard pour identifier les chaînes de requêtes bloquantes. Lancez un test depuis un serveur géographiquement proche de votre audience cible.
- CrUX Dashboard (BigQuery) : pour corréler le LCP par type de page (template). Un site e-commerce aura souvent des LCP très différents entre pages produit (image hero) et pages catégorie (bloc texte + grille d'images).
TTFB : la fondation que tout le monde néglige
Un TTFB de 800ms+ consomme déjà un tiers de votre budget LCP. Sur des architectures SSR, c'est souvent le premier goulot.
Diagnostiquer un TTFB lent
Le TTFB inclut le DNS lookup, la connexion TCP, le handshake TLS, et surtout le temps de génération côté serveur. Sur un site Next.js en SSR, ce dernier composant domine : chaque requête déclenche un getServerSideProps qui peut faire des appels API, des requêtes base de données, du template rendering.
Vérifiez dans votre monitoring APM (Datadog, New Relic, ou même le header Server-Timing) combien de temps le serveur passe en génération. Sur le e-commerce de 12K pages mentionné plus haut, le TTFB était de 1.2s dont 900ms en appels API catalogue — trois requêtes séquentielles vers un PIM Akeneo.
Solutions selon l'architecture
Pour du SSR (Next.js, Nuxt) : passez les pages à fort trafic en ISR (Incremental Static Regeneration) avec un revalidate adapté. Une fiche produit dont le prix change toutes les heures peut tolérer un revalidate: 3600. Le TTFB passe de 900ms à ~50ms pour les requêtes servies depuis le cache. Pour approfondir les trade-offs entre modes de rendering, voir ISR, SSR, SSG : quel mode de rendering pour le SEO.
Pour du SSR pur : implémentez un cache HTTP en amont. Voici une configuration Nginx qui cache les réponses HTML pendant 60 secondes avec stale-while-revalidate :
# /etc/nginx/conf.d/cache-ssr.conf
proxy_cache_path /var/cache/nginx/ssr levels=1:2 keys_zone=ssr_cache:50m
max_size=2g inactive=60m use_temp_path=off;
server {
listen 443 ssl http2;
server_name www.boutique-outdoor.fr;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_cache ssr_cache;
proxy_cache_valid 200 60s;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Header pour diagnostiquer les cache hits en production
add_header X-Cache-Status $upstream_cache_status always;
# stale-while-revalidate côté CDN/navigateur
add_header Cache-Control "public, max-age=30, s-maxage=60, stale-while-revalidate=120" always;
}
}
Le header X-Cache-Status vous permet de vérifier le taux de cache hit en production. Visez 85 %+ sur les pages à fort trafic. Un MISS systématique indique un problème de clé de cache (souvent lié à des query parameters ou cookies qui varient la clé).
Trade-off : le cache HTML SSR implique que les mises à jour de contenu ne sont pas instantanées. Pour un média qui publie des breaking news, un s-maxage=60 est acceptable. Pour un configurateur produit avec personnalisation, ce n'est pas viable — il faudra optimiser les appels serveur eux-mêmes (parallélisation, cache applicatif Redis).
Images : le coupable habituel et les erreurs d'optimisation courantes
Sur la majorité des pages, l'élément LCP est une image. Et la majorité des optimisations qu'on lit partout ("utilisez WebP", "compressez vos images") ne traitent qu'une partie du problème.
Resource Load Delay : l'image LCP n'est pas découverte assez tôt
C'est le problème le plus fréquent et le moins compris. Le navigateur ne peut télécharger une image que s'il la découvre. Si votre image LCP est injectée par JavaScript (un carrousel React, un composant lazy-loaded), le preload scanner du navigateur ne la voit pas dans le HTML brut — il doit attendre l'exécution JS.
C'est précisément le mécanisme qui cause des pages blanches sur les SPA, et le même principe impacte le LCP : ce que le navigateur ne voit pas dans le HTML initial, il ne peut pas le charger en avance.
Solution 1 : <link rel="preload"> dans le <head>
<head>
<!-- Preload de l'image LCP — DOIT matcher exactement le srcset utilisé -->
<link rel="preload"
as="image"
href="/images/hero-camping-tent.webp"
imagesrcset="/images/hero-camping-tent-480.webp 480w,
/images/hero-camping-tent-768.webp 768w,
/images/hero-camping-tent-1200.webp 1200w"
imagesizes="(max-width: 768px) 100vw, 50vw"
fetchpriority="high">
</head>
Attention au piège classique : si le imagesrcset du <link> ne correspond pas exactement au srcset de l'<img>, le navigateur télécharge la ressource deux fois. C'est un gaspillage de bande passante qui empire le LCP au lieu de l'améliorer. Vérifiez dans l'onglet Network de DevTools qu'il n'y a qu'un seul fetch pour l'image hero.
Solution 2 : attribut fetchpriority="high" sur l'<img> LCP
<img src="/images/hero-camping-tent-1200.webp"
srcset="/images/hero-camping-tent-480.webp 480w,
/images/hero-camping-tent-768.webp 768w,
/images/hero-camping-tent-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Tente de camping 4 places en situation"
width="1200"
height="630"
fetchpriority="high"
decoding="async">
fetchpriority="high" indique au navigateur de prioriser cette image dans la file de téléchargement, avant les autres images et les scripts non critiques. Supporté par Chrome, Edge, et Opera (documentation MDN).
Erreur fréquente : loading="lazy" sur l'image LCP. C'est contre-productif. Le lazy loading retarde volontairement le fetch jusqu'à ce que l'image soit proche du viewport — exactement l'inverse de ce que vous voulez pour l'élément LCP. Si vous utilisez Next.js <Image>, vérifiez que le composant hero a bien priority={true} (ce qui désactive le lazy loading et ajoute un preload automatique).
Resource Load Duration : format, compression, CDN
Une fois que l'image est découverte tôt, il faut qu'elle arrive vite.
- Format : AVIF offre 30-50 % de gain en poids par rapport à WebP à qualité visuelle équivalente, mais l'encodage est lent — inadapté pour de la génération à la volée sur des milliers de variantes produit. WebP reste le sweet spot pour la plupart des sites e-commerce. Utilisez l'élément
<picture>avec fallback :
<picture>
<source srcset="/images/hero-480.avif 480w, /images/hero-1200.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
type="image/avif">
<source srcset="/images/hero-480.webp 480w, /images/hero-1200.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
type="image/webp">
<img src="/images/hero-1200.jpg"
alt="Tente de camping 4 places en situation"
width="1200" height="630"
fetchpriority="high"
decoding="async">
</picture>
-
Dimensionnement : servez des images à la taille réelle d'affichage. Une image hero de 2400px de large servie à un viewport mobile de 375px gaspille 80 % de la bande passante. Le
srcsetavec des breakpoints pertinents résout ce problème, à condition que lesizessoit correct. Unsizes="100vw"sur une image qui n'occupe que 50 % du viewport sur desktop double la taille téléchargée inutilement. -
CDN avec edge caching : l'image doit être servie depuis un nœud géographiquement proche de l'utilisateur. Cloudflare, Fastly, ou CloudFront. Vérifiez les headers
cf-cache-statusoux-cachepour confirmer les hits. Un taux de miss élevé sur les images indique souvent des query strings variables (versions, tokens) qui fragmentent le cache.
Element Render Delay : le CSS bloquant le rendu
Même si l'image est chargée, elle ne sera pas peinte tant que le CSSOM n'est pas construit. Un fichier CSS de 450KB non optimisé bloque le rendu de l'ensemble de la page, y compris l'image LCP.
Extrayez le CSS critique (above-the-fold) et injectez-le en inline dans le <head>. Des outils comme Critters (pour les builds Webpack/Next.js) ou critical (npm) automatisent l'extraction. Le reste du CSS se charge en async :
<head>
<style>
/* CSS critique inline — uniquement les styles above-the-fold */
.hero { position: relative; width: 100%; aspect-ratio: 1200/630; }
.hero img { width: 100%; height: auto; display: block; }
/* ... 5-10KB max de CSS critique ... */
</style>
<link rel="preload" href="/css/main.c8f2a1.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/main.c8f2a1.css"></noscript>
</head>
Fonts : le render-blocking silencieux
Si votre élément LCP est un bloc de texte (un <h1> sur une page catégorie, un titre d'article), le LCP dépend directement du temps de chargement des fonts.
Le problème : FOIT et FOUT
Par défaut, la plupart des navigateurs appliquent un FOIT (Flash of Invisible Text) : le texte est invisible pendant que la font se charge, puis apparaît d'un coup. Le texte invisible = pas de LCP tant que la font n'est pas là.
La solution : font-display: swap + preload
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-v13-latin-700.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
font-display: swap affiche immédiatement le texte avec une font système fallback, puis swap vers la font custom quand elle est chargée. Le LCP est enregistré dès l'affichage du texte en fallback — même si la font finale n'est pas encore là.
Combinez avec un preload pour réduire le délai avant le swap :
<link rel="preload" href="/fonts/inter-v13-latin-700.woff2"
as="font" type="font/woff2" crossorigin>
Le crossorigin est obligatoire même si la font est sur votre propre domaine — c'est une spécificité de la spec Fetch pour les fonts (documentation web.dev).
Trade-off : CLS vs LCP
font-display: swap améliore le LCP mais peut dégrader le CLS (Cumulative Layout Shift) si la font system fallback a des métriques très différentes de la font custom. Pour limiter le shift, utilisez des @font-face avec size-adjust, ascent-override, et descent-override :
/* Font fallback avec métriques ajustées pour matcher Inter */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107.64%;
ascent-override: 90%;
descent-override: 22.43%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-fallback', sans-serif;
}
Les valeurs exactes dépendent de votre font. L'outil Fontaine génère automatiquement ces overrides. Next.js 13+ le fait nativement via next/font.
JavaScript et third-parties : les chaînes de latence invisibles
Un script tiers (A/B testing, tag manager, analytics) qui se charge en render-blocking peut retarder le LCP de plusieurs centaines de millisecondes sans que vous le réalisiez.
Identifier les scripts bloquants
Dans Chrome DevTools, onglet Performance, cherchez les longues tâches (barres rouges) qui se produisent avant le marqueur LCP. Un script de consent management qui prend 350ms à s'exécuter avant que le rendu puisse commencer est un classique.
La commande Lighthouse en CLI est utile pour automatiser la détection sur un grand nombre de pages :
# Audit LCP sur 50 URLs de pages produit
cat product-urls.txt | xargs -I {} -P 4 \
npx lighthouse {} \
--only-categories=performance \
--output=json \
--output-path=./reports/{}.json \
--chrome-flags="--headless --no-sandbox" \
--throttling-method=provided \
--preset=desktop
Ensuite, parsez les JSON pour extraire les lcp-lazy-loaded, render-blocking-resources, et unused-javascript diagnostics. Automatisez sur vos 50 URLs les plus trafiquées pour avoir une vision d'ensemble.
Solutions
-
Déplacez les scripts non critiques en
deferouasync. Un tag manager comme GTM devrait toujours être enasync. Les scripts d'A/B testing qui modifient le DOM avant le rendu (anti-flicker snippets) sont les pires — discutez avec votre équipe growth pour évaluer si le test en cours justifie 400ms de LCP en plus sur 100 % du trafic. -
Évaluez l'impact de chaque third-party. Dans WebPageTest, activez l'option "Block third-party" et comparez les résultats. Si bloquer un script fait passer le LCP de 4.2s à 2.8s, vous avez trouvé votre coupable.
-
Self-hosted vs CDN tiers : charger Google Fonts depuis
fonts.googleapis.comajoute une connexion DNS + TCP + TLS vers un domaine tiers. Self-hostez vos fonts (téléchargez les fichiers.woff2) pour éliminer cette latence. Le gain est souvent de 100-300ms sur le Resource Load Delay.
Scénario complet : migration et correction sur un e-commerce de 15K pages
Prenons le cas concret de boutique-outdoor.fr, un e-commerce de matériel de camping sous Next.js (Pages Router, SSR via getServerSideProps). 15 000 pages produit, 200 pages catégorie, 50 pages de contenu éditorial.
Diagnostic initial
Données CrUX (Search Console → Core Web Vitals) :
- LCP mobile : 4.8s (P75) — 68 % des pages en "Poor"
- LCP desktop : 2.9s (P75) — 35 % des pages en "Poor"
Analyse avec le script PerformanceObserver déployé en RUM sur 72h (échantillon de 45 000 sessions) :
- TTFB moyen : 1.3s (SSR
getServerSidePropsavec 3 appels API séquentiels vers le PIM) - Resource Load Delay : 1.4s (image hero injectée par un composant React lazy-loaded, invisible au preload scanner)
- Resource Load Duration : 1.5s (images JPEG non optimisées, 350KB en moyenne, servies sans CDN)
- Element Render Delay : 0.6s (bundle CSS de 380KB en render-blocking)
Corrections appliquées
Phase 1 — TTFB (semaine 1) : Migration des 200 pages catégorie vers ISR avec revalidate: 1800. Parallélisation des 3 appels API dans getStaticProps avec Promise.all(). Mise en place du cache Nginx pour les pages produit (config ci-dessus). TTFB moyen : 1.3s → 280ms.
Phase 2 — Images (semaine 2) : Pipeline de conversion JPEG → WebP + AVIF via sharp dans le build. Ajout de <link rel="preload"> pour l'image hero sur les templates produit et catégorie. Remplacement de loading="lazy" par fetchpriority="high" sur l'image LCP. Mise en place de Cloudflare comme CDN images. Resource Load Delay : 1.4s → 200ms. Resource Load Duration : 1.5s → 400ms.
Phase 3 — CSS et fonts (semaine 3) : Extraction du CSS critique avec Critters. Self-hosting des fonts Inter et Montserrat avec font-display: swap + preload. Ajout des size-adjust overrides pour limiter le CLS. Element Render Delay : 0.6s → 150ms.
Phase 4 — Third-parties (semaine 4) : Audit des scripts tiers. Suppression d'un widget de chat (Intercom) chargé en synchrone sur toutes les pages — remplacé par un chargement on-interaction. Passage de GTM en async. Suppression de l'anti-flicker snippet d'AB Tasty (gain de 380ms).
Résultats après 6 semaines de données CrUX
- LCP mobile P75 : 4.8s → 2.1s
- LCP desktop P75 : 2.9s → 1.3s
- Pages classées "Good" : 68 % → 91 %
- Impact organique constaté : +12 % de pages dans le top 10 sur les requêtes produit (comparaison Search Console mois M vs M-2), corrélé avec l'amélioration des Core Web Vitals mais aussi une refonte des meta descriptions menée en parallèle — impossible d'isoler complètement l'effet LCP seul.
C'est un point important : les Core Web Vitals ont un impact réel sur le classement, mais c'est un signal parmi d'autres. Ne vous attendez pas à doubler votre trafic en optimisant le LCP seul. L'impact est surtout visible dans les situations de tie-break entre pages de qualité comparable.
Monitoring continu : détecter les régressions avant Google
Le LCP n'est pas un problème qu'on résout une fois. Un déploiement qui ajoute un nouveau script third-party, un changement de template qui déplace l'image hero sous le fold, un CDN qui a un incident de cache — chacun de ces événements peut faire régresser le LCP sans que personne ne s'en aperçoive pendant des semaines.
Mettez en place un monitoring RUM permanent avec le script PerformanceObserver présenté plus haut, agrégé par template de page. Définissez des alertes sur les seuils : LCP P75 > 2.5s = warning, > 4s = critical.
Un outil de monitoring comme SEOGard permet de détecter automatiquement ce type de régression technique en continu — un SSR qui casse après un déploiement, une balise preload qui disparaît, une hydration mismatch qui dégrade silencieusement le rendu. L'objectif est d'être alerté avant que les données CrUX à 28 jours ne reflètent la dégradation.
Côté Screaming Frog, lancez un crawl hebdomadaire avec l'extraction custom du header Server-Timing et du poids des images. Croisez avec les données CrUX par URL via l'API CrUX. Automatisez le rapport pour que votre équipe identifie les pages qui dérivent.
Le LCP est un symptôme composite. Chaque milliseconde gagnée vient d'une correction spécifique sur une sous-phase précise. Décomposez, mesurez, corrigez dans l'ordre d'impact — et surtout, monitorez pour que le travail ne soit pas défait au prochain sprint.