Un site e-commerce de 12 000 fiches produit. Migration d'un monolithe PHP vers Next.js terminée depuis trois semaines. Le trafic organique chute de 18 %. Search Console affiche un mur rouge dans le rapport Core Web Vitals : 74 % des URL mobiles échouent sur le LCP. Le seuil de 2,5 secondes est dépassé — souvent au-delà de 4 secondes. Le CTO pense à un problème de crawl. En réalité, c'est un hero image de 1,2 Mo servie sans preload, un TTFB à 1,8 seconde sur les pages catégorie, et des web fonts qui bloquent le rendu pendant 900 ms.
Le LCP est la métrique Core Web Vitals la plus directement corrélée à l'expérience utilisateur perçue. C'est aussi celle qui pose le plus de problèmes en production, parce qu'elle dépend de toute la chaîne : serveur, réseau, rendu HTML, chargement des ressources critiques. Voici comment la diagnostiquer méthodiquement et la corriger avec du code, pas des conseils vagues.
Comprendre ce que mesure réellement le LCP
Le LCP identifie le plus grand élément visible dans le viewport au moment où il finit de se peindre. Pas le plus lourd. Pas le premier. Le plus grand en surface rendue. Les candidats éligibles selon la spécification du W3C sont :
- Les éléments
<img> - Les éléments
<image>dans un SVG - Les éléments
<video>(poster image) - Les éléments avec une
background-imagechargée via CSS - Les blocs de texte (
<p>,<h1>, etc.) — souvent sous-estimés
Le point crucial : le LCP peut changer au fil du chargement. Le navigateur identifie un premier candidat (par exemple un titre H1), puis le remplace quand un élément plus grand apparaît (l'image hero). C'est le dernier candidat avant l'interaction utilisateur qui compte.
Pourquoi le LCP n'est pas un simple problème d'image
Sur les pages catégories d'un e-commerce, le LCP est souvent une image. Sur une page éditoriale d'un média, c'est fréquemment un bloc de texte — et dans ce cas, ce sont les fonts qui dictent le LCP, pas les images. Diagnostiquer sans identifier le bon élément candidat, c'est optimiser à l'aveugle.
Dans Chrome DevTools, l'onglet Performance identifie précisément l'élément LCP. Lancez un profil de chargement, puis cherchez le marqueur "LCP" dans la timeline. Cliquez dessus : DevTools met en surbrillance l'élément DOM concerné. C'est votre point de départ.
Pour du monitoring en production sur des données réelles (field data vs lab data), l'API PerformanceObserver vous donne le LCP tel que vécu par vos utilisateurs :
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP element:', lastEntry.element);
console.log('LCP time:', lastEntry.startTime, 'ms');
console.log('LCP size:', lastEntry.size);
console.log('LCP url:', lastEntry.url); // vide si c'est du texte
// Envoyer vers votre système d'analytics
sendToAnalytics({
metric: 'LCP',
value: lastEntry.startTime,
element: lastEntry.element?.tagName,
url: lastEntry.url || 'text-block',
page: window.location.pathname
});
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
Ce script vous révèle l'élément LCP réel par page. Sur le site e-commerce mentionné plus haut, ce monitoring a montré que 60 % des pages produit avaient un LCP sur l'image principale, mais 40 % l'avaient sur le titre H1 — parce que l'image était lazy-loadée et que le titre apparaissait en premier dans le viewport. Deux problèmes distincts, deux stratégies de correction différentes.
Décomposer le LCP en sous-phases
Google décompose le temps LCP en quatre sous-phases dans sa documentation officielle. Chaque phase est un levier d'optimisation indépendant :
- Time to First Byte (TTFB) — temps entre la requête et le premier octet de la réponse HTML
- Resource Load Delay — temps entre le TTFB et le début du chargement de la ressource LCP
- Resource Load Duration — temps de téléchargement de la ressource LCP elle-même
- Element Render Delay — temps entre la fin du chargement et le rendu effectif
Diagnostiquer la phase dominante
Utilisez ce script pour mesurer chaque sous-phase en production :
const LCP_SUB_PARTS = [];
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
const navEntry = performance.getEntriesByType('navigation')[0];
const ttfb = navEntry.responseStart;
// Resource timing pour l'élément LCP (si c'est une image/ressource)
let resourceLoadDelay = 0;
let resourceLoadDuration = 0;
if (lastEntry.url) {
const resourceEntries = performance.getEntriesByType('resource');
const lcpResource = resourceEntries.find(e => e.name === lastEntry.url);
if (lcpResource) {
resourceLoadDelay = lcpResource.requestStart - ttfb;
resourceLoadDuration = lcpResource.responseEnd - lcpResource.requestStart;
}
}
const elementRenderDelay = lastEntry.startTime - (ttfb + resourceLoadDelay + resourceLoadDuration);
console.table({
'TTFB': Math.round(ttfb),
'Resource Load Delay': Math.round(resourceLoadDelay),
'Resource Load Duration': Math.round(resourceLoadDuration),
'Element Render Delay': Math.round(elementRenderDelay),
'Total LCP': Math.round(lastEntry.startTime)
});
}).observe({ type: 'largest-contentful-paint', buffered: true });
Sur le cas e-commerce, la décomposition a révélé : TTFB de 1 800 ms (72 % du LCP total). L'image elle-même ne prenait que 200 ms à charger. Optimiser l'image n'aurait eu aucun effet significatif — le problème était côté serveur.
Ce type de décomposition est la différence entre un diagnostic amateur ("les images sont trop lourdes") et un diagnostic professionnel ("le TTFB de la page catégorie est à 1,8 s parce que la requête SQL de listing produit n'est pas cachée").
Corriger le TTFB : la fondation du LCP
Un TTFB supérieur à 800 ms rend pratiquement impossible un LCP sous 2,5 secondes, quel que soit le niveau d'optimisation des ressources frontend. Le mode de rendu joue un rôle majeur ici — si vous hésitez entre SSR, SSG et ISR, les implications sur le TTFB sont détaillées dans cet article sur les modes de rendering.
Cache HTTP agressif côté serveur
Pour des pages dont le contenu change peu (fiches produit, pages catégorie), un cache serveur avec stale-while-revalidate est le levier le plus efficace :
# Nginx — config pour pages produit avec cache micro-CDN
location ~ ^/produit/ {
proxy_pass http://app_backend;
proxy_cache product_cache;
proxy_cache_valid 200 10m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
# Headers de cache pour le CDN amont (Cloudflare, Fastly, etc.)
add_header Cache-Control "public, max-age=60, stale-while-revalidate=600";
add_header X-Cache-Status $upstream_cache_status;
# Compression Brotli pour le HTML
brotli on;
brotli_types text/html application/json;
brotli_comp_level 4;
}
# Pages catégorie — cache plus long car mise à jour batch
location ~ ^/categorie/ {
proxy_pass http://app_backend;
proxy_cache category_cache;
proxy_cache_valid 200 30m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503;
proxy_cache_background_update on;
add_header Cache-Control "public, max-age=300, stale-while-revalidate=3600";
}
Sur le site e-commerce en question, cette configuration a fait passer le TTFB des pages catégorie de 1 800 ms à 120 ms (cache hit). Le LCP médian est passé de 4,1 s à 1,9 s — sous le seuil "Good" — sans toucher à une seule image.
SSG ou ISR pour les pages à fort trafic
Si vous utilisez Next.js, la stratégie ISR (Incremental Static Regeneration) élimine le TTFB variable en pré-générant le HTML. Pour un catalogue de 12 000 produits, vous ne pouvez pas tout pré-builder au deploy. ISR résout ce problème :
// pages/produit/[slug].tsx — Next.js ISR
export const getStaticProps: GetStaticProps = async ({ params }) => {
const product = await getProductBySlug(params.slug as string);
if (!product) {
return { notFound: true };
}
return {
props: { product },
revalidate: 600, // revalide toutes les 10 min
};
};
export const getStaticPaths: GetStaticPaths = async () => {
// Pré-builder uniquement le top 500 produits par trafic
const topProducts = await getTopProductsBySessions(500);
return {
paths: topProducts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking', // SSR on-demand pour le reste, puis cache
};
};
Le fallback: 'blocking' signifie que la première visite sur une page non pré-buildée sera en SSR (TTFB plus élevé), mais toutes les visites suivantes recevront du HTML statique. En SEO, c'est Googlebot qui subit souvent ce premier hit — mais une fois la page générée, elle est servie en statique aux visiteurs suivants et aux crawls ultérieurs.
Le choix entre SSR et CSR a des implications profondes sur le SEO au-delà du LCP. Si votre site est une SPA, les problèmes de page blanche côté Google aggravent les impacts d'un LCP médiocre.
Optimiser le chargement de la ressource LCP
Une fois le TTFB maîtrisé, la phase "Resource Load Delay" est souvent le second coupable. Le problème : le navigateur ne peut pas commencer à charger l'image LCP tant qu'il n'a pas parsé le HTML jusqu'à l'endroit où l'image est référencée. Si cette image est injectée par du JavaScript, le délai explose.
Preload explicite de la ressource LCP
Le <link rel="preload"> dans le <head> déclenche le chargement de la ressource LCP immédiatement, avant même que le parser HTML n'atteigne l'élément <img> dans le body :
<head>
<!-- Preload de l'image LCP — CRITIQUE pour le LCP des pages produit -->
<link
rel="preload"
as="image"
href="/images/products/nike-air-max-90-hero.webp"
type="image/webp"
fetchpriority="high"
imagesrcset="/images/products/nike-air-max-90-hero-400w.webp 400w,
/images/products/nike-air-max-90-hero-800w.webp 800w,
/images/products/nike-air-max-90-hero-1200w.webp 1200w"
imagesizes="(max-width: 768px) 100vw, 50vw"
/>
<!-- Preconnect au CDN d'images si différent du domaine principal -->
<link rel="preconnect" href="https://cdn.monsite.fr" crossorigin />
<!-- NE PAS preload les images below-the-fold -->
</head>
<body>
<!-- L'image LCP dans le body — PAS de loading="lazy" ici -->
<img
src="/images/products/nike-air-max-90-hero-800w.webp"
srcset="/images/products/nike-air-max-90-hero-400w.webp 400w,
/images/products/nike-air-max-90-hero-800w.webp 800w,
/images/products/nike-air-max-90-hero-1200w.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
width="800"
height="600"
alt="Nike Air Max 90 — vue de profil"
fetchpriority="high"
decoding="async"
/>
</body>
Trois erreurs fréquentes sur le preload d'images LCP :
1. Preload sans imagesrcset — Si votre <img> utilise srcset, le preload doit le répliquer avec imagesrcset et imagesizes. Sans ça, le navigateur télécharge l'image du preload ET celle du srcset. Double download. Performance dégradée.
2. loading="lazy" sur l'image LCP — C'est le bug le plus courant. Le lazy loading retarde explicitement le chargement de l'image tant qu'elle n'est pas proche du viewport. Sur l'image LCP, c'est catastrophique. Google le signale dans Lighthouse, mais en production ça passe souvent inaperçu sur des templates générés dynamiquement qui appliquent loading="lazy" à toutes les images indistinctement.
3. Preload sur toutes les pages — Le preload est une promesse au navigateur : "cette ressource est critique". Si vous preloadez des ressources qui ne sont pas utilisées sur la page, vous gaspillez de la bande passante et le navigateur affiche un warning console. Chaque template doit avoir son propre preload adapté.
L'attribut fetchpriority
L'attribut fetchpriority="high" (supporté dans Chrome depuis la version 101) permet de signaler au navigateur que cette ressource doit être priorisée dans la file de téléchargement. Sans cet attribut, les images sont par défaut en priorité "Low" pendant le chargement initial, même si elles sont dans le viewport.
La combinaison preload + fetchpriority="high" + absence de loading="lazy" est le trio gagnant pour l'image LCP.
Fonts : le tueur silencieux du LCP textuel
Quand le LCP est un bloc de texte — titre H1, paragraphe d'introduction — ce sont les web fonts qui dictent le timing. Le mécanisme est le suivant : le navigateur peint le texte avec la font de fallback, puis quand la web font arrive, il re-peint. Le LCP est mesuré au moment du rendu final avec la bonne font.
Si votre font est chargée depuis Google Fonts ou un CDN tiers, vous ajoutez un DNS lookup + connexion TCP + téléchargement. Sur mobile 4G, ça représente facilement 500-1200 ms.
Self-hosting et preload des fonts
<head>
<!-- Preload de la font critique (celle utilisée pour le titre/body principal) -->
<link
rel="preload"
as="font"
href="/fonts/inter-var-latin.woff2"
type="font/woff2"
crossorigin
/>
<style>
/* Font-face avec font-display: swap pour un rendu immédiat */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var-latin.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Fallback system font stack qui matche les métriques d'Inter */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
ascent-override: 90.49%;
descent-override: 22.56%;
line-gap-override: 0%;
size-adjust: 107.06%;
}
body {
font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}
</style>
</head>
Le @font-face avec les overrides de métriques (ascent-override, descent-override, size-adjust) réduit le layout shift quand la web font remplace le fallback. Sans ces ajustements, le swap de font cause un CLS visible — et potentiellement un changement d'élément LCP si le bloc de texte change de taille.
L'outil Fontaine de l'équipe UnJS génère automatiquement ces overrides de métriques à partir de vos fichiers font. Pour Next.js, next/font fait ce travail automatiquement.
Pourquoi font-display: optional peut être mieux que swap
font-display: swap garantit un rendu immédiat du texte (bon pour le LCP), mais cause un flash de texte non stylé (FOUT) visible par l'utilisateur. font-display: optional supprime ce flash : si la font n'est pas disponible dans les ~100 ms, le navigateur utilise le fallback pour toute la durée de la session.
Le trade-off : avec optional, le premier chargement utilise la font système. Les visites suivantes (font en cache) utilisent la web font. Pour un média ou un SaaS où la brand consistency est critique, c'est un compromis acceptable. Pour un e-commerce où le LCP textuel est rare (les images dominent), swap reste préférable.
Images : format, dimensionnement et responsive
La taille brute de l'image LCP impacte directement la phase "Resource Load Duration". Sur mobile 4G (débit effectif moyen de 4-8 Mbps), une image de 500 Ko prend 500-1000 ms à télécharger. Une image de 80 Ko prend 80-160 ms. La différence est directement mesurable dans le LCP.
Pipeline d'optimisation d'images
Pour un site à 12 000+ pages, l'optimisation d'images doit être automatisée. Voici un pipeline concret avec Sharp (Node.js) :
// scripts/optimize-product-images.ts
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
const QUALITY_WEBP = 80;
const QUALITY_AVIF = 65; // AVIF compresse mieux, qualité perçue équivalente à WebP 80
const WIDTHS = [400, 800, 1200];
async function optimizeImage(inputPath: string): Promise<void> {
const basename = path.basename(inputPath, path.extname(inputPath));
const outputDir = path.join('public/images/optimized', path.dirname(
path.relative('assets/images', inputPath)
));
const image = sharp(inputPath);
const metadata = await image.metadata();
for (const width of WIDTHS) {
// Ne pas upscale
if (metadata.width && width > metadata.width) continue;
// WebP — support navigateur > 97%
await sharp(inputPath)
.resize(width, null, { withoutEnlargement: true })
.webp({ quality: QUALITY_WEBP, effort: 6 })
.toFile(path.join(outputDir, `${basename}-${width}w.webp`));
// AVIF — support navigateur ~92%, meilleure compression
await sharp(inputPath)
.resize(width, null, { withoutEnlargement: true })
.avif({ quality: QUALITY_AVIF, effort: 6 })
.toFile(path.join(outputDir, `${basename}-${width}w.avif`));
}
console.log(`✓ ${basename}: ${WIDTHS.length * 2} variants generated`);
}
async function main() {
const images = await glob('assets/images/products/**/*.{jpg,jpeg,png}');
console.log(`Processing ${images.length} images...`);
// Traitement en parallèle par batch de 10
for (let i = 0; i < images.length; i += 10) {
const batch = images.slice(i, i + 10);
await Promise.all(batch.map(optimizeImage));
}
}
main();
Résultat typique sur le catalogue e-commerce : images hero produit passées de 180 Ko en JPEG à 45 Ko en WebP et 32 Ko en AVIF, à qualité perçue identique. Sur 12 000 fiches, le gain cumulé est massif pour le crawl budget (moins de bande passante consommée par Googlebot) et pour le LCP utilisateur.
L'impact de la taille des pages sur le crawl est documenté — Google a confirmé que les pages deviennent de plus en plus lourdes et que cela a des conséquences sur l'indexation.
L'élément <picture> pour servir le bon format
<picture>
<!-- AVIF en premier — plus petite taille, support croissant -->
<source
type="image/avif"
srcset="/images/optimized/products/nike-air-max-90-hero-400w.avif 400w,
/images/optimized/products/nike-air-max-90-hero-800w.avif 800w,
/images/optimized/products/nike-air-max-90-hero-1200w.avif 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<!-- WebP fallback -->
<source
type="image/webp"
srcset="/images/optimized/products/nike-air-max-90-hero-400w.webp 400w,
/images/optimized/products/nike-air-max-90-hero-800w.webp 800w,
/images/optimized/products/nike-air-max-90-hero-1200w.webp 1200w"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<!-- JPEG fallback ultime -->
<img
src="/images/optimized/products/nike-air-max-90-hero-800w.jpg"
width="800"
height="600"
alt="Nike Air Max 90 — vue de profil"
fetchpriority="high"
decoding="async"
/>
</picture>
Un point subtil : quand vous utilisez <picture> avec preload, le preload doit correspondre au format qui sera effectivement chargé. Chrome gère le preload avec type="image/webp", mais si le navigateur supporte AVIF et charge la source AVIF, le preload WebP est gaspillé. La solution pragmatique : preloadez le format WebP (couverture navigateur > 97 %) et acceptez que les navigateurs AVIF-compatibles fassent un double download marginal sur le preload.
Audit à grande échelle et monitoring continu
Corriger le LCP sur une page est un exercice technique. Le maintenir sous 2,5 secondes sur 12 000 pages qui évoluent en continu — nouvelles images uploadées par l'équipe produit, modifications de templates, mises à jour de dépendances — c'est un défi opérationnel.
Audit initial avec Lighthouse CI
Intégrez Lighthouse CI dans votre pipeline de déploiement pour détecter les régressions avant qu'elles n'atteignent la production :
# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
http://localhost:3000/categorie/chaussures-running
http://localhost:3000/produit/nike-air-max-90
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
# lighthouse-budget.json contient :
# { "ci": { "assert": {
# "assertions": {
# "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
# "uses-responsive-images": ["warn", { "minScore": 0.9 }]
# }
# }}}
Cela bloque les PR qui dégradent le LCP au-delà du seuil. Mais Lighthouse CI teste en lab — des conditions réseau et CPU synthétiques. Les données terrain (field data) peuvent différer significativement.
Données terrain via CrUX et Search Console
Le rapport Core Web Vitals de Search Console utilise les données CrUX (Chrome User Experience Report). Ce sont les données réelles des utilisateurs Chrome. C'est ce que Google utilise comme signal de ranking. L'impact réel des Core Web Vitals sur le classement est documenté — c'est un facteur de départage, pas un facteur dominant, mais sur des requêtes compétitives ça fait la différence.
Pour un diagnostic granulaire par URL pattern, l'API CrUX est plus utile que Search Console :
# Requête CrUX API pour un pattern d'URL spécifique
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": "https://www.monsite.fr",
"formFactor": "PHONE",
"metrics": ["largest_contentful_paint"]
}' | jq '.record.metrics.largest_contentful_paint'
La réponse vous donne la distribution p75, les pourcentages "good" / "needs improvement" / "poor". Comparez ces chiffres avec vos données Lighthouse CI pour identifier l'écart lab vs field.
Monitoring des régressions en production
Le risque principal n'est pas l'état initial — c'est la dérive. Un développeur ajoute un carrousel JavaScript en hero. Le chef de produit uploade une image PNG de 3 Mo comme bannière catégorie. L'équipe ops change la config CDN et casse le cache.
Un outil de monitoring comme Seogard détecte automatiquement ces régressions sur le rendu et les meta de vos pages — le type de changement qui se traduit directement en dégradation de performance et de visibilité organique. Coupler ça avec un monitoring RUM (Real User Monitoring) via la PerformanceObserver API vue plus haut vous donne une couverture complète : alertes sur les changements techniques + données de performance réelles.
Cas particulier : LCP et applications JavaScript côté client
Si votre SPA charge le contenu principal via un appel API côté client, le LCP inclut le temps de :
- Téléchargement du bundle JS
- Parsing et exécution du JS
- Appel API
- Réponse API
- Rendu du contenu
C'est mécaniquement plus lent que du HTML servi côté serveur. C'est aussi la raison pour laquelle le SSR a un impact mesurable sur le SEO au-delà du seul aspect d'indexation.
Le pattern de correction : streamer le HTML critique côté serveur. En Next.js App Router ou en React Server Components, le contenu above-the-fold (incluant l'élément LCP) est inclus dans la réponse HTML initiale. Les parties interactives sont hydratées progressivement. Si vous rencontrez des problèmes d'hydration mismatch, le LCP peut être impacté par un re-render complet côté client.
Pour les sites qui utilisent encore du dynamic rendering pour servir du HTML à Googlebot, attention : le LCP mesuré par Cr