Error! NO_LCP: How 4 Pixels Broke Our PageSpeed - and How We Hunted Them All Night
PageSpeed stopped measuring LCP: Error! NO_LCP. Three hours, eight hypotheses, a PerformanceObserver probe - the culprit: 4 pixels of carousel padding.
A routine check of a cleaning company’s website in PageSpeed Insights. I expected the usual numbers and saw this instead:
Largest Contentful Paint Error! NO_LCP
Total Blocking Time Error! NO_LCP
The main speed metric - the one Google grades websites by - was simply not being measured. Not “poor”, not “red”. It did not exist. Everything else looked decent: FCP 1.4 seconds, a 96/100 score. And on top of that, five diagnostic audits were failing with the word “Error”: “Minify CSS - Error”, “Reduce unused JavaScript - Error”.
We ran it again. And again. Mobile, desktop - NO_LCP everywhere.
The first theory was the comfortable one: Google is glitching. The day before, the service had been honestly replying “Too many render requests”, and five broken audits hinted that the measurement engine could not collect data. So we checked with independent tools. Local Lighthouse, same version 13.4.1 Google runs, - LCP measured fine at 1.9 seconds. SpeedVitals with a server in Germany - Grade A, 94%, LCP 2.1. CrUX field data over 28 days - LCP 1.7, Core Web Vitals passed. Three systems out of four measure fine. So it must be the PageSpeed engine acting up, case closed?
Not so fast. We ran a second site through PSI - my personal one, with no fancy internals. Measured perfectly. So it was not Google’s service. Something on this particular site was killing the measurement.
What followed was the classic suspect lineup. Every hypothesis looked bulletproof, every one brought a partial improvement, and none was the root cause.
The infinite carousel of 22 partner logos - a known LCP troublemaker, a large constantly moving element. We delayed its start by 2 seconds. NO_LCP still there.
The semi-transparent hero image: the first-screen photo had opacity 0.3, and recent Chrome versions exclude semi-transparent images from LCP candidates. We made the image opaque and recreated the muted look with an opaque overlay - recalculated the color math, pixel-identical. Local LCP became beautiful, 1.6 seconds. PSI - NO_LCP.
Call tracking. We enabled realistic network throttling in Lighthouse, like Google uses, - and local LCP shot up to 6.8 seconds. The trace showed nine LargestContentfulPaint::Invalidate events: candidates were being reset over and over. Call-stack correlation pointed at the phone number replacement script - it rewrites numbers in the DOM, and every rewrite reset the candidate. We disabled the analytics in test mode: LCP dropped from 6.7 to 3.9. Better - but NO_LCP in PSI would not budge. Spoiler: call tracking was innocent, it was merely piling onto someone else’s crime.
Fonts. A web font swap repaints the headings - a late repaint could be overriding LCP. We set font-display: optional with a metrically compatible fallback. Miss.
The bloated trace. Ours weighed 25 MB - animations hammering 60 fps through the whole test, and Google’s engine has limits: overflow means lost events. We rebuilt the logo strip on IntersectionObserver so it only spins when visible - the trace slimmed down by a third. Local score 95. PSI - guess what.
The score by 3 AM: five hypotheses, five partial improvements (the site genuinely got faster!), zero solutions.
Then came the key decision of the night - stop interpreting Lighthouse reports and ask the browser directly what it thinks about LCP. We took a fresh Chrome, the Canary build closest to what Google runs, launched it via puppeteer and injected the simplest possible probe into the page:
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});
The control site produced two LCP entries - a paragraph, then a section. Normal. Our site - almost no entries at all. In the best run, exactly one: the tiny header logo, 8,016 square pixels. The huge H1 heading, the hero image covering a third of the screen - the browser acted as if they did not exist.
The LCP stream was not glitching. It was dying within the first second of load. And per spec, LCP observation stops irreversibly for exactly two reasons: user input… or a scroll.
We added a scroll listener to the probe and blocked every single script on the page. Pure HTML+CSS, not one line of JS.
scrolls: 4, first at 1193 ms
A page without a single script was scrolling itself. Four scroll events at 1.2 seconds - exactly when styles get applied. There was our LCP killer. All that remained was to learn its name.
We extended the probe to log which element scrolls:
{"at": 1139, "target": "DIV.works-track", "left": 3}
{"at": 1154, "target": "DIV.works-track", "left": 4}
works-track is the “Our work” carousel with before-and-after cleaning photos. Its CSS:
.works-track {
overflow-x: auto;
scroll-snap-type: x mandatory; /* here it is */
padding: 4px 4px 12px; /* and here */
}
The mechanics of the bug. Because of the 4px padding, the first slide sits 4 pixels away from the edge of the scroll container. scroll-snap-type: mandatory demands the slide be pinned exactly to the snap point. The moment styles are applied, the browser scrolls the carousel those 4 pixels by itself - no JS, pure CSS. And that scroll event - programmatic, inside an inner container, 4 pixels! - permanently stops LCP observation in recent Chrome. Done. No element can ever become the LCP. PageSpeed shrugs: Error! NO_LCP.
My second site has no snap carousels - which is why it measured fine. And call tracking with the fonts were just elbowing each other inside an already dead LCP stream.
The fix is one line:
.works-track {
scroll-padding-inline: 4px; /* snap point = actual slide position */
}
The snap point now accounts for the padding, the first slide starts out “in place”, the browser has nothing to auto-scroll. Verified with the probe: zero scroll events, three healthy LCP entries - logo, H1 heading, hero image at its rightful 314,000 square pixels.
Before and after. Before: Error! NO_LCP in every PSI run, CLS 0.447 in the red zone, five diagnostic audits erroring out. After: LCP 0.9 seconds desktop and 2.9 mobile - measured, CLS 0.008-0.01 (down 50x), Performance 89-93, all audits alive, field Core Web Vitals passed. As a bonus the site objectively got faster: an opaque hero LCP candidate, the image on a CDN, a frugal logo strip, fonts with no layout shifts, a cleaned-up critical CSS.
The bill: about three hours of investigation, eight hypotheses, some fifteen runs across four tools, two pieces of evidence dug out of raw Chrome traces. The solution - one line of CSS against four pixels.
What is worth taking away. scroll-snap-type: mandatory combined with padding on the container is a landmine: the browser auto-scrolls to the snap point on load and kills the LCP measurement, so a scroll-padding equal to the padding should always be there. Don’t guess from reports - ask the browser: ten minutes with PerformanceObserver via puppeteer gave more than two hours of interpreting Lighthouse. Partial improvement is a trap: every false hypothesis improved something and kept up the illusion of being on the right track, while the root cause only surfaced through isolation - a script-free page that still scrolled itself. And keep a control handy: the second site that measured fine turned out to be the most valuable fact of the whole night - it ruled out “Google is broken” and forced us to look for the cause at home.
Could this have hurt the site and its SEO?
The short answer: not yet - but we defused the mine before it went off.
The mechanics worked in our favor. Google ranks sites not by lab runs but by CrUX field data - metrics from real visitors over 28 days. And for a real person, the very first tap or scroll stops LCP observation before the bug gets a chance to fire. So the whole time the lab was showing “Error!”, the field numbers stayed green: LCP 1.7 seconds, CLS 0, Core Web Vitals passed. Rankings did not suffer.
But calling it a “harmless glitch” would be lying to ourselves. The bomb was ticking in four directions at once.
We were flying blind. With a dead LCP there is no way to notice real degradation: a slower server, heavier images, a contractor shipping a broken script - PageSpeed would show the same “Error!”, and the problem would live unnoticed for months.
Real defects were already piling up. Alongside the NO_LCP mystery we found and fixed genuine layout shifts: the site header was jumping on load (CLS 0.36 - that is a lot), the guarantees block was rebuilding itself in front of the visitor. Field CLS held at zero mostly thanks to returning visitors with a warm cache. But this site is precisely in the business of growing search traffic - meaning a growing share of new visitors with a cold cache who see every jump. Another month or two of growth and field CLS would have crept up. And that is no longer the lab - that is a direct ranking factor.
Chrome keeps updating. The stricter LCP logic that caught our auto-scroll lives first in the browser’s test builds - which is exactly why the bug showed up in Google’s measurements before reaching users. But test builds become stable ones. Had we not fixed the carousel, a few Chrome versions later the same logic would have reached real browsers - and the field metrics.
And reputation. Any auditor, contractor or client’s marketer opening PageSpeed and seeing “Error! NO_LCP” with five broken audits would draw the obvious conclusion: “the site is broken.” You can argue otherwise with field charts in hand, but the aftertaste stays.
Which brings up perhaps the main practical takeaway of the whole story: green field metrics are not a reason to ignore a broken lab. Field data shows the past - the last 28 days of your existing audience. The lab shows the future - what a new visitor and a new browser will see. When they contradict each other, it is not “one of them glitching” - it is a gap, and the problem is hiding inside it.
P.S. The investigation was run in tandem with an AI assistant (Claude), which drove the test runs, parsed 25-megabyte Chrome traces and wrote the probes. We failed the hypotheses together, so the win is split fifty-fifty too.