Ingénierie de la performanceJune 13, 2026·6 min de lecture·Miracle KaluMiracle Kalu

Comment nous avons réduit le LCP de 4,5 s à moins de 2,5 s sur un site éditorial Next.js 16

Rows of server racks in a modern data center with blue lighting

L'élément LCP a changé, et c'était le premier indice

Il y a quelques mois, j'ai participé à une mission de sauvetage des performances sur un site éditorial à fort trafic tournant sous Next.js 16 App Router, Turbopack et Docker auto-hébergé. Lighthouse était à 79, le LCP à 4,5 s et le TTFB à 1,7 s.

L'élément LCP était le widget JW Player en haut de l'article. Un lecteur vidéo est l'un des pires candidats pour le LCP : iframe, script lourd, image poster et souvent autoplay. Chrome mesure le LCP pour <video> à la première image rendue ou au poster. Dans les deux cas, on lutte contre l'ordre de chargement du lecteur.

Nous n'avons pas commencé par compresser des images. Nous avons commencé par empêcher le lecteur de concurrencer le premier rendu.

Pourquoi les lecteurs vidéo cassent le LCP

Les sites éditoriaux placent des lecteurs vidéo près du haut des articles parce que les vues comptent. Le navigateur traite le lecteur comme le plus grand élément visible, et le LCP ne peut pas se déclencher tant que le script n'est pas téléchargé, exécuté, et que le premier frame ou l'image poster n'est pas rendu.

Sur une connexion lente, jwplayer.js et ses dépendances peuvent coûter 400–800 ms d'exécution sur le thread principal. Si le lecteur est en autoplay, le navigateur récupère aussi les segments vidéo. S'il utilise un poster, ce poster devient le candidat LCP et doit être récupéré avec une priorité élevée. La plupart des implémentations ne font ni l'une ni l'autre correctement.

La solution est une façade : un placeholder léger côté serveur, puis le vrai lecteur uniquement à l'interaction. Le LCP se règle sur le placeholder statique, et le lecteur complet paie son coût plus tard.

// sanitizedContent.ts
export function lazifyJwPlayer(html: string) {
  return html.replace(
    /<iframe[^>]+src="(https:\/\/cdn\.jwplayer\.com\/players\/[^"]+)"[^>]*><\/iframe>/g,
    `<div class="jw-facade" data-src="$1" style="aspect-ratio:16/9;background:#000">
       <button aria-label="Charger le lecteur vidéo">▶</button>
     </div>`
  );
}

Nous avons arrêté de charger le script JW Player au chargement initial. La façade rendait une boîte noire 16:9 avec un bouton de lecture. Ce seul changement a fait passer le LCP de 4,5 s à environ 2,8 s.

Puis le titre est devenu le LCP

Une fois le lecteur différé, l'élément LCP est passé au titre de l'article. Cela semblait être un progrès — le texte est petit et rapide — mais le titre résidait dans ArticleHero, un client component. À cause de la frontière 'use client', le <h1> ne s'affichait qu'après que React ait téléchargé, parsé et hydraté le bundle. Sur un téléphone milieu de gamme en 4G lente, cela ajoutait environ 800–1200 ms au LCP.

La correction était de transformer ArticleHero en server component et d'extraire les parties interactives dans une petite client island. En pratique, cela signifiait démêler une navbar de 354 lignes, un contexte de thème qui enveloppait tout le <body>, et des scripts d'analyse qui pensaient devoir s'exécuter avant que l'utilisateur ne voie quoi que ce soit.

J'ai rétréci le contexte pour qu'il n'enveloppe plus que {children} dans <main>. J'ai importé la navbar dynamiquement avec un placeholder de 60 px. J'ai déplacé FundingChoices, les chargeurs de publicités et le footer hors de l'hydratation initiale. Le titre est devenu du HTML côté serveur.

Mise en cache : l'autre moitié du TTFB

Un TTFB de 1,7 s n'était pas un problème de capacité serveur, mais un problème de cache. Les pages de catégorie avaient export const dynamic = 'force-dynamic', ce qui désactivait chaque couche de mise en cache offerte par Next.js.

J'ai remplacé cela par export const revalidate = 60 et aligné les TTL de unstable_cache sur la même fenêtre. Le détail clé était la clé de cache. Deux appelants passaient le même slug avec des formats de barre oblique différents, manquaient donc tous les deux le cache et exécutaient la requête backend deux fois. La normalisation en posts-by-slug-${cleanedSlug} a supprimé ce doublon.

Auto-hébergé derrière Cloudflare, le CDN peut écraser Cache-Control. J'ai ajouté des directives CDN explicites :

// next.config.ts
async headers() {
  return [
    {
      source: "/_next/static/chunks/:path*",
      headers: [
        { key: "Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
      ],
    },
    {
      source: "/media/:path*",
      headers: [
        { key: "Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "CDN-Cache-Control", value: "public, max-age=31536000, immutable" },
        { key: "Surrogate-Control", value: "public, max-age=31536000, immutable" },
      ],
    },
  ];
}

CDN-Cache-Control et Surrogate-Control comptent car Cloudflare et Fastly les respectent même quand ils réécrivent le Cache-Control standard. Pour les pages ISR, j'ai utilisé s-maxage=60, stale-while-revalidate=86400 afin que l'edge puisse servir instantanément une page en cache tout en revalidant en arrière-plan.

Le TTFB est passé de 1,7 s à environ 0,3–0,5 s sur les hits de cache.

Hygiène des images et polices LCP

Sur les pages sans vidéo, l'élément LCP est devenu la première image de l'article. J'ai arrêté de la charger paresseusement, ajouté fetchpriority="high" et loading="eager", et plafonné toutes les images d'article à 1024 px de large. La qualité d'image est passée de q-90 à q-70, puis à q-60, ce qui a fait fondre les payloads de 25 à 35 %.

Un audit ultérieur montrait un délai de rendu de 730 ms sur le <h1>. TTFB à 0 ms, donc le délai venait presque entièrement du CSS bloquant. J'ai extrait ~170 lignes de CSS non critique dans une feuille différée, supprimé les variantes dark-mode inutilisées, réduit Inter de cinq graisses à une, puis retiré Inter pour revenir à la pile système. J'ai aussi inliné les styles critiques above-the-fold dans layout.tsx.

Scripts tiers et INP

Après JW Player, le plus gros contributeur était les embeds Twitter. widgets.js coûtait 816 ms sur le thread principal, même sous la fold. J'ai construit des façades click-to-load pour Twitter, Instagram et TikTok. Le sanitizer côté serveur neutralise les iframes en déplaçant src vers data-src ; une client component rend des placeholders et charge le SDK à l'interaction.

// SocialEmbedFacades/index.tsx (simplifié)
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      const sdk = entry.target.getAttribute("data-sdk");
      if (sdk) loadSdk(sdk);
      observer.unobserve(entry.target);
    }
  });
}, { rootMargin: "300px" });

J'ai ajouté un fallback d'auto-activation : après la première interaction, les façades restantes se chargent une seconde plus tard via requestIdleCallback. GTM a été différé derrière load + 3 s + requestIdleCallback. Chartbeat a été triple-gated : lazyOnload, window.load, puis requestIdleCallback.

Pour l'INP, j'ai enveloppé le scroll handler dans requestAnimationFrame avec { passive: true }, mis offsetWidth en cache avec useRef, et debouncé le resize hook. J'ai aussi découpé les chunks JS first-party avec splitChunks à 100 Ko et ajouté @sentry/nextjs à optimizePackageImports.

Résultats

| Métrique | Avant | Après | | --- | --- | --- | | LCP | 4,5 s | ~2,0–2,5 s | | TTFB | 1,7 s | ~0,3–0,5 s | | Lighthouse | 79 | ~90–95 |

La prochaine fois, je commencerais par l'audit d'architecture plutôt que par la checklist Lighthouse. La plupart des gains venaient de la stratégie de rendu et de la mise en cache. Et la première chose à vérifier est l'élément LCP lui-même : un lecteur vidéo ne devrait jamais posséder le premier rendu.

Takeaways

  • Si votre élément LCP est un lecteur vidéo, différez-le avec une façade. Cela coûte presque rien et retire une charge tierce massive du chemin critique.
  • Faites rendre votre élément LCP texte côté serveur. Si le titre s'hydrate, vous payez une taxe inutile.
  • Alignez toutes les couches de cache. Next.js, unstable_cache et le CDN doivent partager le même TTL et les mêmes clés de cache.
  • Utilisez CDN-Cache-Control et Surrogate-Control quand vous vous auto-hébergez derrière Cloudflare ou Fastly ; le Cache-Control standard ne suffit pas toujours.
  • Les scripts tiers sont le moyen le plus rapide de ruiner un budget performance. Traitez-les comme coupables jusqu'à preuve du contraire.
  • L'INP se gagne en supprimant les forced reflow et en découpant les longues tâches, pas en ajoutant des bibliothèques.
  • Mesurez les sous-parties du LCP. Un TTFB de 0 ms avec un délai de rendu de 730 ms vous dit exactement où chercher.

Partager :

XLinkedIn
Miracle Kalu

Écrit par

Miracle Kalu

Senior Full Stack Engineer

Vous avez aimé cet article ?

Je suis disponible pour des rôles d'ingénierie senior et du conseil technique. Parlons-en.

Prendre contact →

Publié le 13 juin 2026 · 6 min de lecture