On a vu un site e-commerce perdre 1,2 seconde de LCP deux semaines après sa migration vers Next.js App Router. Le pire, c’est que l’équipe avait suivi la checklist officielle. Server Components cochés. Streaming activé. Images en next/image. Et pourtant, le LCP avait basculé de 2,1s à 3,3s sur mobile.
Pourquoi ? Parce que le problème ne venait pas de la techno précédente. Il venait du fait que personne n’avait mesuré où le LCP atterrissait vraiment dans la nouvelle architecture. Le rendu serveur renvoyait le HTML en 90ms, le navigateur l’affichait, et l’image principale se retrouvait bloquée derrière une cascade de requêtes que le code précédent n’avait pas.
Les technologies web actuelles ne sont ni un remède ni un poison. Elles déplacent le problème. La question n’est pas « est-ce que je migre vers la dernière stack », c’est « qu’est-ce que je mesure avant, pendant et après pour ne pas me réveiller avec une Search Console en feu ».
Le mirage des Server Components pour le LCP
On te dira que les React Server Components réduisent le bundle JS, donc améliorent le LCP. C’est vrai sur le papier. Dans la réalité, le LCP se joue à trois endroits : le TTFB, le temps de chargement de la ressource LCP, et le délai de rendu. Les Server Components n’agissent que sur le troisième, et seulement si le composant LCP lui-même était précédemment rendu côté client.
J’ai audité cinq projets React migrés en 2025. Résultat constant : le TTFB augmente de 40% à 120% après migration. Logique : le serveur fait plus de boulot, les appels DB et API tournent côté Node.js au lieu de tourner dans le navigateur, et la latence réseau entre votre serveur et l’utilisateur reste la même. Sur un site dont le LCP était une image chargée en 400ms, le passage à un TTFB de 1,1s annule le gain d’un bundle allégé.
Le piège classique, c’est de comparer les audits Lighthouse avant/après en mode « desktop, throttling simulé ». Lighthouse desktop ne pèse pas le TTFB avec la même sévérité que le throttling mobile. Un TTFB qui passe de 150ms à 600ms reste dans le vert desktop. Il explose en rouge sur mobile avec une 4G simulée. Si votre audience est majoritairement mobile, ce que la Search Console vous dira probablement, vous venez de dégrader votre signal CWV.
⚠️ Attention : un Server Component qui fetch des données depuis une API externe sur chaque requête transforme votre TTFB en addition de latences. Si l’API répond en 400ms et que vous n’avez pas de cache HTTP en amont, votre LCP ne descendra jamais sous la seconde, quelle que soit la légèreté du client.
Pour sortir de ce piège, mesurez le TTFB réel avant migration. Pas le TTFB Lighthouse, celui de votre monitoring RUM. Si vous êtes déjà au-dessus de 600ms sur le 75e percentile mobile, migrer sans revoir votre couche de données ou sans CDN en edge, c’est empirer le problème.
Hydration partielle : pourquoi votre INP n’a pas bougé
L’hydration partielle, c’est la promesse de React 19 tenue jusqu’au bout : seuls les composants interactifs sont hydratés, le reste reste du HTML statique. Résultat théorique : moins de JS exécuté, un INP (Interaction to Next Paint) qui chute.
Sur le papier, encore une fois, c’est impeccable. En pratique, on a testé ça sur une page produit avec un sélecteur de taille, un carrousel d’images et un bouton d’ajout au panier. L’hydration partielle a bien réduit le volume de JS hydraté de 35%. L’INP est resté bloqué à 280ms.
Pourquoi ? Parce que le premier composant interactif hydraté, le sélecteur de taille, déclenchait un useEffect avec un appel réseau synchrone vers un stock local, suivi d’un calcul de disponibilité. Le long task n’était pas dans le volume de JS, il était dans la logique métier du premier bloc interactif. L’hydration partielle avait élagué les branches mortes, pas la racine du mal.
C’est là que l’outillage change tout. Avant de célébrer une migration React, ouvrez votre onglet Performance dans Chrome DevTools, cochez « CPU throttling 4x », enregistrez une interaction sur le composant incriminé, et lisez la durée du long task. Si le premier bloc interactif dépasse 150ms d’exécution, votre INP restera au-dessus des seuils même avec 0% de JS superflu.
J’ai croisé une équipe qui avait résolu ça en inversant l’approche : au lieu d’hydrater le sélecteur de taille en premier, ils ont hydraté un wrapper statique, puis lazy-loadé le calcul de stock dans un requestIdleCallback. INP passé de 280ms à 90ms. Pas de framework magique, juste une compréhension fine de ce qui bloquait le thread principal.
Le choix du state management dans React avec Zustand devient critique ici : un store global qui recalcule à chaque hydratation partielle peut ruiner vos gains. Zustand, en particulier, permet de souscrire seulement aux slices nécessaires, ce qui limite l’exécution à l’hydratation du composant.
Edge rendering : TTFB imbattable, LCP inchangé
Le edge rendering promet un TTFB sous les 100ms en exécutant le rendu au plus près de l’utilisateur. Sur un site avec une page statique, ça fonctionne. Sur une fiche produit avec des données dynamiques, le TTFB edge ne sert à rien si la ressource LCP (l’image principale) est toujours servie depuis un bucket S3 dans une seule région.
On a mesuré ça sur une boutique de meubles déployée sur Vercel avec edge activé. TTFB : 65ms en France métropolitaine. LCP : 1,8s. La raison ? L’image principale faisait 380 Ko, était chargée en priority: high mais servie depuis une origine distante sans CDN d’images. Le navigateur recevait le HTML en 65ms, découvrait l’URL de l’image, et devait attendre 1,6s de latence réseau pour la charger.
La leçon est brutale : le edge rendering n’améliore le LCP que si toute la chaîne de dépendances de la ressource LCP est elle-même distribuée en edge. Si votre HTML arrive vite mais que votre image LCP traîne, vous avez juste gagné 65ms d’affichage d’une page sans son contenu principal.
📌 À retenir : le LCP se mesure au moment où le navigateur a fini d’afficher le plus grand élément visible. Le edge rendering accélère le début de cette course, pas la ligne d’arrivée.
Le vrai coût du lazy-loading automatique
Les frameworks modernes ont démocratisé le lazy-loading des images et des composants. Attribut loading="lazy" sur toutes les images, dynamic(() => import(...)) sur tous les composants sous la ligne de flottaison. L’intention est louable. L’exécution automatique, elle, peut dégrader le LCP de façon insidieuse.
Voici le scénario qu’on a vu sur quatre audits récents. Une page article avec une image d’en-tête en pleine largeur. Le développeur applique loading="lazy" sur toutes les balises <img> de son composant, y compris celle qui est dans le viewport. Résultat : le navigateur ne charge pas l’image LCP en priorité haute, il la traite comme une image hors-écran. Le LCP passe de 1,4s à 2,7s. Seul indice visible : dans l’onglet Network, l’image LCP apparaît en 12e position dans la cascade, derrière des icônes SVG et une police Google Fonts.
Le mécanisme est documenté dans les spécifications HTML : une image avec loading="lazy" qui se trouve dans le viewport initial peut être chargée avec une priorité réduite, selon l’heuristique du navigateur. Chrome, en particulier, applique une heuristique de distance au viewport qui peut rétrograder l’image si elle est marquée lazy. Ce n’est pas un bug, c’est la spécification qui autorise cette interprétation.
La correction est simple en apparence : supprimer loading="lazy" sur l’image LCP et la remplacer par fetchpriority="high". Mais dans un framework qui génère les balises automatiquement, repérer l’image LCP parmi 200 composants image optimisés, c’est une autre paire de manches. Next.js, par exemple, applique loading="lazy" par défaut sur next/image pour les images non prioritaires, et seul le flag priority bascule le comportement. Oublier ce flag sur l’image au-dessus du pli, c’est signer pour un LCP dégradé.
Le cas du lazy-loading illustre un problème plus large avec les technologies web actuelles : l’abstraction éloigne le développeur de la cascade réseau réelle. On configure des props, on lit la doc, on suppose que le framework « fait ce qu’il faut ». Mais le framework ne sait pas quelle image est votre LCP. Vous, si. À condition d’ouvrir l’onglet Network et de vérifier.
Si vous travaillez sur une stratégie d’optimisation Core Web Vitals complète, ne laissez pas le lazy-loading automatique décider à votre place. Identifiez la ressource LCP réelle dans vos données de terrain (pas dans Lighthouse), et appliquez fetchpriority="high" explicitement sur cette ressource. Le reste de la page peut rester en lazy sans dommage.
Mesurer avant de migrer : le protocole qu’on applique
Quand un client nous annonce une migration vers une nouvelle stack front, on pose trois mesures avant d’écrire une ligne de code. Ce protocole prend deux heures et évite des semaines de débogage post-migration. Le voici.
D’abord, on extrait les données CrUX (Chrome User Experience Report) sur les 28 derniers jours via l’API publique ou via la Search Console. On ne regarde pas les moyennes, on regarde le 75e percentile pour le LCP, l’INP et le CLS, segmenté par type d’appareil. L’objectif n’est pas d’avoir un chiffre rassurant, c’est d’avoir une baseline opposable. Si le LCP mobile 75e percentile est à 3,2s aujourd’hui et qu’il passe à 4,1s après migration, on ne pourra pas dire qu’on ne savait pas.
Ensuite, on identifie précisément la ressource LCP sur les cinq pages les plus visitées du site. Pas avec Lighthouse, qui choisit parfois un élément différent de ce que les vrais utilisateurs voient. On utilise l’API Web Vitals en mode console.log dans un environnement réel, ou on lit l’attribut lcp_element dans les rapports RUM si on a un monitoring. Pour chaque page, on note l’URL de la ressource LCP, son domaine, son type MIME, et la présence ou non d’un fetchpriority explicite.
Enfin, on mesure le TTFB réel segmenté par région géographique. Un site qui fait 200ms de TTFB à Paris peut faire 800ms à La Réunion si le serveur est en Europe continentale et que le cache CDN ne couvre pas tout. On utilise webpagetest.org avec trois localisations, ou les données de notre RUM si on a assez de volume. Cette segmentation par région devient la contrainte architecturale : si 15% du trafic est à plus de 500ms de TTFB, la migration doit inclure un déploiement edge ou un CDN qui couvre ces zones.
Ces trois mesures prennent moins de temps qu’une réunion de cadrage et donnent un cap : on sait quel chiffre on doit battre, quelle ressource doit arriver vite, et où le TTFB nous limite. Tout le reste, le choix entre Server Components, le edge rendering, l’hydration partielle, se décide en fonction de ces contraintes, pas de la hype du moment.
L’alternative, c’est de migrer d’abord et de mesurer ensuite. On l’a fait une fois. Le site a perdu 40% de son crawl organique en trois semaines, parce que les pages produits mettaient plus de temps à répondre et que Googlebot a réduit son budget de crawl. La Search Console ne pardonne pas les migrations non mesurées.
Les outils évoluent vite dans cet écosystème. Savoir choisir entre Claude Code et Cursor IDE pour débugger une cascade réseau ou auditer un composant serveur, ce n’est pas un détail : c’est le quotidien de ceux qui mesurent avant d’agir.
Questions fréquentes
Est-ce qu’un CDN seul suffit à résoudre le TTFB après migration ?
Un CDN réduit la latence réseau, mais si votre serveur d’origine met 600ms à générer le HTML parce qu’il attend une API produit, le CDN ne peut pas accélérer ce temps de traitement. Il faut soit un cache HTTP en amont avec une durée de vie adaptée, soit un rendu statique par incrément (ISR) pour les pages qui le tolèrent.
Les frameworks comme Astro ou Qwik changent-ils la donne pour le LCP ?
Astro, en ne livrant aucun JS par défaut, élimine le problème de l’hydration mais pas celui du TTFB ni de la ressource LCP. Qwik, avec son hydration différée, résout l’INP mais le LCP reste dépendant de la cascade réseau. Aucun framework ne vous dispense de mesurer où atterrit votre plus grand élément visible.