Le mystère du LCP introuvable
Si vous surveillez la perf de vos pages, vous scrutez régulièrement les valeurs de vos LCP. Eh bien, amateurs de sensations fortes, tenez-vous bien : un LCP peut devenir introuvable. Pour être exact, il peut n’être mesurable ni dans Lighthouse ni dans PageSpeed, et pire, être complètement oblitéré des données CrUX ! C’est ce qui est arrivé à Gabriel, dans l’équipe de développement de cap-adrenaline.com. Sans prévenir, alors que la page consacrée au saut en parachute en tandem affichait des Core Web Vitals tout à fait corrects dans le CrUX, le LCP a disparu… Quel impact une telle disparition peut-elle avoir sur le positionnement de cette page ? Et surtout, pourquoi ?
Spoiler : le coupable n’était pas là où on le cherchait, et il mesurait un pixel et demi…
Portrait de la victime : qui est vraiment le LCP ?
Petit rappel pour être sûr qu’on parle de la même chose. Le LCP, pour Largest Contentful Paint, c’est le temps que met le navigateur à afficher le plus gros élément visible à l’écran au chargement : une image, le poster d’une vidéo, une image de fond chargée en CSS, ou tout simplement un bloc de texte. Google le considère comme bon sous 2,5 secondes, mesuré sur le 75e percentile des visites réelles.
Petite précision : le LCP n’est pas calculé une seule fois. Pendant le chargement, Chrome émet une suite de candidats : un titre se peint, il devient candidat ; une image plus grande s’affiche ensuite, elle prend sa place ; et ainsi de suite jusqu’à ce que la page soit chargée. Le dernier candidat enregistré devient le LCP de la page.
Jusque-là, aucune notion ne permet de déterminer la cause d’un LCP introuvable. Et pourtant…
Cette mesure a une règle d’arrêt, que j’avais lue sans lui accorder de l’importance : dès la première interaction de l’utilisateur (un scroll, un clic, une touche du clavier), Chrome cesse définitivement d’émettre des candidats au LCP ( cf. web.dev ) . Pourquoi cette limite ? Car une fois que l’utilisateur a bougé la page, « le plus grand élément visible au chargement » ne veut plus dire grand-chose.
À qui profite le crime ?
Avant d’aller plus loin, il faut répondre à la question de départ : un LCP introuvable, est-ce grave pour le référencement ? Eh bien la réponse est plus nuancée qu’on pourrait le croire.
Quand une page n’a pas assez de données terrain, Google ne la laisse pas sans note. Il se rabat sur un groupe de pages similaires, voire sur le site entier. John Mueller, porte-parole de Google auprès des webmasters et des SEO, l’a d’ailleurs confirmé assez clairement : pour une URL sans données, Google s’appuie sur celles de son groupe. Notre victime a donc vécu sur la réputation de ses voisines. Pas de sanction, mais plus aucune visibilité sur ses vraies performances.
Premier suspect : l’interaction utilisateur
Tentons une reconstitution. Si une interaction survient avant même que le premier élément éligible ait été peint, Chrome n’a enregistré aucun candidat au moment où il arrête de chercher. Ce n’est pas un LCP lent, ni un LCP médiocre, c’est un LCP introuvable. Lighthouse le traduit par une erreur NO_LCP, et côté terrain, il n’y a rien à remonter dans le CrUX. Le mobile du crime est trouvé, et il colle parfaitement aux symptômes.

Sauf que ce suspect a un alibi en béton. Lors d’un run Lighthouse, personne ne touche à la page : pas de doigt sur l’écran, pas de molette, pas de clavier. Et pourtant à chaque test Lighthouse sur PageSpeed, on obtenait un NO_LCP.
Usual Suspect : JavaScript
Au cours de nos tests manuels, le problème était reproductible une fois sur cinq environ. Et là, il y avait quelque chose de contre-intuitif : le LCP disparaissait sur les runs rapides a contrario des runs lents. Même des runs avec des TTFB monstrueux avaient un LCP. Sur une page avec un FCP à 616 ms, visuellement stable en 899 ms, et un CLS à zéro, bref une page en très bonne santé ; et pourtant un LCP introuvable… Et pas d’énorme TBT ou de longue charge CPU ce qui écartait de facto mon coupable favori, le JavaScript n’était pas la cause du problème.
À cette étape de l’enquête, on devait reconnaitre que quelque chose dans la page se comportait comme une interaction, sans qu’aucun utilisateur ne soit là, et ce quelque chose se déclenche tellement vite qu’il intervient avant que la page s’affiche. Je commençais franchement à m’arracher le peu de cheveux qui me reste.
Aux grands maux les grands remèdes : je proposai une méthode pour résoudre le souci. La bonne vieille méthode de l’expérimentation où l’on supprime progressivement des éléments de la page jusqu’à trouver le coupable.
Fausses pistes et faux témoins
La méthode a le mérite de la simplicité : on retire un bloc, on relance, on compare. Et très vite, un premier suspect sort du lot : sans les avis clients, le LCP revient. Restait à comprendre pourquoi.
Première explication : le poids. Avec le bloc, la page semblait charger environ 56 Ko de plus. Fausse piste. Entre les deux runs, le numéro de version des fichiers statiques avait changé, donc toutes les URL différaient : 215 différences, presque toutes fausses. Une fois corrigé, il ne restait que deux scripts d’écart.
Et ces 56 Ko, c’était quoi ? L’interface de la bannière de consentement Didomi, chargée ou non selon l’état du cookie. Là, on tenait un suspect sérieux, parce qu’une bannière de consentement peut réellement supprimer le LCP : un overlay par-dessus la page, un overflow: hidden posé sur le body, et plus rien d’éligible à l’écran. C’est d’ailleurs un vrai sujet pour les premières visites en production, je le note dans un coin. Mais pas ici : en relançant sans la moindre requête Didomi, le LCP était toujours aux abonnés absents.
Et je ne vous parle pas des runs infructueux où les TTFB étaient incohérents entre eux et où d’autres critères rendaient toute comparaison inexploitable.
Par contre, quand les comparaisons devinrent cohérentes, nous avions toujours le même constat : sans le bloc d’avis, le LCP revenait.
Le coupable habitait à 2461 px
Le bloc d’avis clients se présente sous la forme d’un carrousel horizontal : les avis défilent de gauche à droite, et à chaque geste, le carrousel se cale proprement sur l’avis suivant. Ce comportement est assuré en CSS, sur le conteneur qui fait défiler les avis :
scroll-snap-type: x mandatory;
Le scroll-snap cher à mon cœur qui permet de nous affranchir d’une masse de JavaScript conséquente et des charges CPU qui l’accompagnent. Presque une trahison à mes yeux. Mais comment expliquer ce comportement : le mot important, c’est mandatory (traduction en français : obligatoire). Ce n’est donc pas une préférence, mais une obligation : le conteneur doit être aligné sur un point d’accroche à tout instant, pas seulement quand l’utilisateur finit de scroller.
Or au chargement, le contenu du carrousel bouge : les polices arrivent, les images se décodent, le display: flex se met en place. À chaque fois, l’alignement est perdu, et le navigateur recale le conteneur tout seul. Gabriel l’a mesuré sous Lighthouse : à 429 ms, le carrousel avait défilé de 1,5 px. Ce décalage si infime soit-il est considéré dans le contexte par Chrome comme une interaction… Et comme dit ci-dessus, dès la première interaction de l’utilisateur (un scroll, un clic, une touche du clavier), Chrome cesse définitivement d’émettre des candidats au LCP.
Et pour couronner le tout, peu importe que ce carrousel soit tout en bas de la page, et donc en dehors du viewport. La règle d’arrêt vaut pour toute la page : un conteneur qui bouge de 1,5 px même à 2461 px du haut suffit à empêcher la mesure du LCP.

L’heure du crime : chargement ou rendu ?
Tout se joue en fait sur une sorte de course, et il faut bien distinguer deux temps.
- Le temps de chargement, c’est celui que mettent le HTML et les ressources à arriver depuis le serveur.
- Le temps de rendu, c’est celui que met le navigateur à calculer la mise en page et à peindre l’écran, et celui-là dépend surtout du processeur.
Quand le chargement est lent, la page arrive par morceaux : le haut s’affiche avant même que le carrousel, tout en bas, soit en place. Chrome a donc le temps d’enregistrer un candidat au LCP avant le moindre recalage. C’est sans doute pour ça que nos runs avec des TTFB monstrueux avaient un LCP.
Quand le chargement est rapide, tout arrive presque en même temps, et c’est le rendu qui décide. Si la page est peinte avant le recalage du carrousel, le LCP existe. Sinon, il disparaît. Sur un ordinateur rapide, la page gagne le plus souvent, d’où l’intermittence de nos tests à la main. Mais un test Lighthouse en Slowdown x4 ralentit le processeur par quatre : il ne touche pas au chargement, il allonge le rendu, juste assez pour perdre la course à chaque fois. Et sur le terrain, les mobiles moins puissants se retrouvent probablement dans la même situation, ce qui expliquerait la disparition du LCP dans le CrUX.
Résolution et retour du LCP introuvable
Le coupable identifié, plusieurs actions étaient possibles.
Certains pourraient vouloir remplacer le scroll-snap du carrousel par du JavaScript : hérésie !!! N’oubliez pas que le JavaScript doit être utilisé avec parcimonie et qu’en l’occurrence le code CSS ici présent est juste coupable d’être trop rapide, un comble.
Autre piste : passer de mandatory à proximity. Le navigateur ne recale le conteneur que s’il est proche d’un point d’accroche, ce qui devrait limiter les recalages au chargement. Mais le comportement au geste devient moins net, et rien ne garantissait que le problème disparaisse totalement. Bref, une solution bof.
Ma solution
La solution que je préconise dans ce cas-là : stabiliser le contenu du carrousel (dimensions réservées, polices préchargées) pour qu’il ne bouge plus au chargement. En théorie, c’est le plus propre, en pratique et en fonction du contexte ça peut être un peu compliqué.
Ensuite il y a une autre astuce : poser content-visibility: auto sur le bloc qui contient le carrousel. Cette propriété demande au navigateur de ne pas calculer la mise en page ni l’affichage d’un élément tant qu’il est loin du viewport. Pas de mise en page, donc pas de contenu qui bouge, donc pas d’alignement à recaler. Le carrousel ne serait réellement rendu qu’à l’approche de l’utilisateur, bien après le LCP.
Bonus : le navigateur économise le rendu d’un bloc que beaucoup de visiteurs ne verront jamais. Seule précaution, lui associer un contain-intrinsic-size pour réserver sa hauteur, sinon la barre de défilement saute quand le bloc s’affiche. Là encore, c’est un peu compliqué à gérer en fonction du responsive car la hauteur peut varier.
La solution de Gabriel
La solution retenue par Gabriel est plus pragmatique : le carrousel était déjà initialisé par un IntersectionObserver qui ne se déclenche qu’à l’approche du viewport, pour tous les boutons qui ne sont pas encore à ce jour implémentables en CSS (suivant, précédent, position). Il suffisait de définir une classe « cap-snap-ready » qui contient le scroll-snap et qui est appliquée sur le conteneur en même temps par l’IntersectionObserver. Soit pas de scroll-snap au chargement, donc rien à recaler, donc aucun scroll, donc la mesure du LCP n’est plus jamais interrompue. Le bloc étant à 2461 px, le snap ne s’active que bien après le LCP, et seulement si l’utilisateur descend jusque-là. Côté UX, rien ne change : quand on arrive sur le carrousel, il se cale comme avant.
Le correctif est passé en production un lundi soir. Dès le lendemain matin, le LCP introuvable refaisait surface.
Affaire classée ?
Le LCP est revenu, l’affaire est classée. Mais je retiens surtout qu’une mesure n’est jamais acquise. Cette page avait des Core Web Vitals corrects dans le CrUX, et du jour au lendemain, l’indicateur a disparu. Un score vert n’est pas une garantie pérenne, c’est une photo à un instant donné.
On notera qu’il a fallu recourir aux bonnes vieilles méthodes pour trouver le coupable. Face à un bug qu’on ne comprend pas, rien ne vaut la bonne vieille expérimentation : on retire, on relance, on compare. À condition de comparer ce qui est comparable, et ce n’est pas toujours le plus simple.
Et non, cette histoire ne me fera pas renier mes convictions. Le scroll-snap n’a pas été coupable d’être lent, il a été coupable d’être trop rapide. Le correctif ne l’a d’ailleurs pas remplacé par du JavaScript, il s’est contenté de le faire patienter avec une simple classe. Le CSS reste plus rapide et plus léger que le JS, il faut juste savoir à quel moment le laisser agir.
Enfin, c’est le genre de cas qu’on ne rencontre pas dans les tutoriels. On le rencontre avec un client, sur une vraie page, avec de vrais visiteurs. C’est là qu’on progresse, et c’est aussi pour ça que j’aime ce métier. Merci à Gabriel d’avoir promu nos outils et à Cap-Adrénaline pour ce saut dans le vide bien réceptionné tout de même. De mon côté, je repars enquêter sur une vague histoire de nonce pour sécuriser vos pages web…
Visuel généré à partir d’une intelligence artificielle.