Error! NO_LCP : comment 4 pixels ont cassé notre PageSpeed, et comment on les a traqués toute la nuit
PageSpeed ne mesurait plus le LCP : Error! NO_LCP. Trois heures, huit hypothèses, une sonde PerformanceObserver - le coupable : 4 pixels de padding d'un carrousel.
Un contrôle de routine du site d’une société de nettoyage dans PageSpeed Insights. J’attendais les chiffres habituels, et voilà ce que je vois :
Largest Contentful Paint Error! NO_LCP
Total Blocking Time Error! NO_LCP
La métrique principale de vitesse - celle sur laquelle Google note les sites - n’était tout simplement pas mesurée. Pas « mauvaise », pas « rouge ». Inexistante. Le reste avait pourtant l’air correct : FCP à 1,4 seconde, score 96/100. Et en prime, cinq audits de diagnostic tombaient avec le mot « Erreur » : « Réduisez la taille du CSS - Erreur », « Supprimez le JavaScript inutilisé - Erreur ».
On relance. Encore. Mobile, desktop - NO_LCP partout.
La première version était la plus confortable : c’est Google qui bugue. La veille, le service répondait honnêtement « Too many render requests », et cinq audits cassés laissaient penser que le moteur de mesure n’arrivait pas à collecter les données. On a vérifié avec des outils indépendants. Lighthouse en local, même version 13.4.1 que chez Google : LCP mesuré, 1,9 seconde. SpeedVitals, serveur en Allemagne : Grade A, 94 %, LCP 2,1. Données terrain CrUX sur 28 jours : LCP 1,7, Core Web Vitals validés. Trois systèmes sur quatre mesurent normalement. Donc c’est le moteur de PageSpeed qui déraille, on peut rentrer ?
Pas si vite. On a passé un deuxième site dans PSI - le mien, personnel, sans mécanique complexe. Mesuré parfaitement. Ce n’était donc pas le service de Google. Quelque chose sur ce site précis tuait la mesure.
Ensuite, le défilé classique des suspects. Chaque hypothèse semblait en béton, chacune apportait une amélioration partielle, et aucune n’était la racine du mal.
Le bandeau défilant de 22 logos partenaires - cause connue de problèmes de LCP, un grand élément en mouvement permanent. On a retardé son démarrage de 2 secondes. NO_LCP toujours là.
L’image hero semi-transparente : la photo du premier écran était en opacity 0.3, et les versions récentes de Chrome excluent les images semi-transparentes des candidats au LCP. On a rendu l’image opaque et recréé le rendu assombri avec un calque opaque - calcul des couleurs refait, identique au pixel près. Le LCP local est devenu superbe, 1,6 seconde. Dans PSI : NO_LCP.
Le call tracking. On a activé dans Lighthouse un ralentissement réseau réaliste, comme chez Google - et le LCP local a bondi à 6,8 secondes. Dans la trace, neuf événements LargestContentfulPaint::Invalidate : les candidats étaient réinitialisés en boucle. La corrélation des piles d’appels a désigné le script de remplacement des numéros de téléphone : il réécrit les numéros dans le DOM, et chaque réécriture annulait le candidat. On a coupé l’analytics en mode test : LCP tombé de 6,7 à 3,9. Mieux - mais le NO_LCP dans PSI n’a pas bougé. Spoiler : le call tracking était innocent, il ne faisait qu’aggraver le crime d’un autre.
Les polices. Le swap d’une police web repeint les titres - un repaint tardif pouvait écraser le LCP. On a posé font-display: optional avec un fallback métriquement compatible. Raté.
La trace obèse. La nôtre pesait 25 Mo - les animations tournaient à 60 fps pendant toute la mesure, et le moteur de Google a des limites : en cas de débordement, des événements se perdent. On a rebâti le bandeau sur IntersectionObserver, pour qu’il ne tourne que lorsqu’il est visible - la trace a maigri d’un tiers. Score local 95. PSI - devinez.
Le score à trois heures du matin : cinq hypothèses, cinq améliorations partielles (le site est objectivement devenu plus rapide !), zéro solution.
C’est là qu’est venue la décision clé de la nuit : arrêter d’interpréter les rapports Lighthouse et demander directement au navigateur ce qu’il pense du LCP. On a pris un Chrome tout frais, la version Canary la plus proche de celle qui tourne chez Google, lancé via puppeteer, et injecté dans la page la sonde la plus simple possible :
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
log({time: e.startTime, size: e.size, element: e.element});
}
}).observe({type: 'largest-contentful-paint', buffered: true});
Le site témoin a produit deux entrées LCP - un paragraphe, puis une section. Normal. Notre site : presque aucune entrée. Dans le meilleur passage, une seule : le minuscule logo de l’en-tête, 8 016 pixels carrés. L’énorme titre H1, l’image hero occupant un tiers de l’écran - le navigateur faisait comme s’ils n’existaient pas.
Le flux LCP ne « buguait » pas. Il mourait dans la première seconde du chargement. Or, selon la spécification, l’observation du LCP s’arrête irréversiblement pour exactement deux raisons : une action de l’utilisateur… ou un défilement.
On a ajouté à la sonde un écouteur de scrolls, et bloqué absolument tous les scripts de la page. HTML+CSS pur, pas une ligne de JS.
scrolls: 4, premier à 1193 ms
Une page sans un seul script défilait toute seule. Quatre événements de scroll à 1,2 seconde - pile au moment de l’application des styles. Le voilà, le tueur du LCP. Restait à connaître son nom.
On a élargi la sonde pour journaliser quel élément défile :
{"at": 1139, "target": "DIV.works-track", "left": 3}
{"at": 1154, "target": "DIV.works-track", "left": 4}
works-track, c’est le carrousel « Nos réalisations » avec les photos avant-après. Son CSS :
.works-track {
overflow-x: auto;
scroll-snap-type: x mandatory; /* le voilà */
padding: 4px 4px 12px; /* et le voilà */
}
La mécanique du bug. À cause du padding de 4 pixels, la première diapositive se trouve à 4 pixels du bord du conteneur de défilement. scroll-snap-type: mandatory exige que la diapositive soit plaquée exactement sur le point d’accroche. Au moment où les styles s’appliquent, le navigateur fait défiler lui-même le carrousel de ces 4 pixels - aucun JS, du CSS pur. Et cet événement de scroll - programmatique, d’un conteneur interne, de 4 pixels ! - arrête définitivement l’observation du LCP dans les Chrome récents. Terminé. Plus aucun élément ne peut devenir le LCP. PageSpeed lève les bras : Error! NO_LCP.
Mon deuxième site n’a aucun carrousel à snap - voilà pourquoi il se mesurait sans problème. Quant au call tracking et aux polices, ils ne faisaient que se bousculer dans un flux LCP déjà mort.
Le correctif tient en une ligne :
.works-track {
scroll-padding-inline: 4px; /* point d'accroche = position réelle de la diapositive */
}
Le point d’accroche prend désormais le padding en compte, la première diapositive est « à sa place » d’entrée, le navigateur n’a plus rien à faire défiler. Vérification à la sonde : zéro événement de scroll, trois entrées LCP saines - le logo, le titre H1, l’image hero avec ses 314 000 pixels carrés légitimes.
Les chiffres avant-après. Avant : Error! NO_LCP dans toutes les mesures PSI, CLS à 0,447 en zone rouge, cinq audits de diagnostic en « Erreur ». Après : LCP à 0,9 seconde sur desktop et 2,9 sur mobile - mesuré, CLS à 0,008-0,01 (divisé par 50), Performance 89-93, tous les audits vivants, Core Web Vitals terrain validés. En bonus, le site est objectivement plus rapide : candidat LCP hero opaque, image sur CDN, bandeau économe, polices sans décalages, critical CSS nettoyé.
L’addition : environ trois heures d’enquête, huit hypothèses, une quinzaine de mesures avec quatre outils, deux indices extraits des traces brutes de Chrome. La solution : une ligne de CSS contre quatre pixels.
Ce qu’il faut en retenir. scroll-snap-type: mandatory combiné à un padding sur le conteneur est une mine : le navigateur défile tout seul vers le point d’accroche au chargement et tue la mesure du LCP - un scroll-padding égal au padding doit toujours être là. Ne devinez pas d’après les rapports, interrogez le navigateur : dix minutes de PerformanceObserver via puppeteer ont donné plus que deux heures d’interprétation de Lighthouse. L’amélioration partielle est un piège : chaque fausse hypothèse améliorait quelque chose et entretenait l’illusion d’être sur la bonne voie, alors que la racine n’est apparue que par isolation - une page sans un seul script qui défilait quand même toute seule. Et gardez un témoin sous la main : le deuxième site, qui se mesurait normalement, s’est révélé le fait le plus précieux de toute la nuit - il a exclu le « Google est cassé » et forcé à chercher la cause chez soi.
Est-ce que cela aurait pu plomber le site et son SEO ?
Réponse courte : pas encore - mais nous avons désamorcé la mine avant l’explosion.
La mécanique a joué en notre faveur. Google classe les sites non pas sur les mesures de laboratoire, mais sur les données terrain CrUX - les métriques des visiteurs réels sur 28 jours. Or, chez un humain réel, le tout premier tap ou scroll arrête l’observation du LCP avant que le bug n’ait le temps d’agir. Ainsi, pendant tout le temps où le labo affichait « Error! », les indicateurs terrain restaient verts : LCP à 1,7 seconde, CLS à 0, Core Web Vitals validés. Les positions n’ont pas souffert.
Mais parler de « bug inoffensif » serait se mentir à soi-même. La bombe tictaquait dans quatre directions à la fois.
Nous volions à l’aveugle. Avec un LCP mort, impossible de remarquer une vraie dégradation : un serveur qui ralentit, des images qui s’alourdissent, un prestataire qui livre un script bancal - PageSpeed aurait affiché la même « Error! », et le problème aurait vécu des mois sans être vu.
De vrais défauts s’accumulaient déjà. En parallèle de l’énigme NO_LCP, nous avons trouvé et corrigé de véritables décalages de mise en page : l’en-tête du site sautait au chargement (CLS 0,36 - c’est beaucoup), le bloc des garanties se reconstruisait sous les yeux du visiteur. Le CLS terrain tenait à zéro surtout grâce aux visiteurs récurrents au cache chaud. Or ce site est justement en train de faire croître son trafic de recherche - donc la part de nouveaux visiteurs au cache froid, qui voient tous les sauts. Encore un mois ou deux de croissance, et le CLS terrain aurait grimpé. Et là, ce n’est plus le laboratoire - c’est un facteur de classement direct.
Chrome se met à jour. La logique LCP durcie qui a attrapé notre auto-scroll vit d’abord dans les versions de test du navigateur - c’est précisément pourquoi le bug est apparu dans les mesures de Google avant d’atteindre les utilisateurs. Mais les versions de test deviennent stables. Sans la correction du carrousel, quelques versions de Chrome plus tard, la même logique serait arrivée dans les navigateurs réels - et dans les métriques terrain.
Et la réputation. N’importe quel auditeur, prestataire ou marketeur du client, ouvrant PageSpeed et voyant « Error! NO_LCP » avec cinq audits cassés, en tirerait la conclusion évidente : « le site est cassé ». On peut prouver le contraire graphiques terrain à l’appui, mais le doute reste.
D’où, sans doute, la principale leçon pratique de toute cette histoire : des métriques terrain vertes ne sont pas une raison d’ignorer un laboratoire cassé. Les données terrain montrent le passé - les 28 derniers jours de l’audience existante. Le laboratoire montre l’avenir - ce que verront un nouveau visiteur et un nouveau navigateur. Quand ils se contredisent, ce n’est pas « l’un des deux qui bugue » - c’est une faille, et le problème se cache dedans.
P.S. L’enquête a été menée en binôme avec un assistant IA (Claude), qui lançait les mesures, analysait des traces Chrome de 25 Mo et écrivait les sondes. On a raté les hypothèses ensemble, la victoire se partage donc aussi moitié-moitié.