Some runs had no LCP at all
This site runs Lighthouse on every push. It checks the public routes three times each on the desktop preset and fails the build if Largest Contentful Paint (LCP) goes over budget.
For a while, that check was unreliable. Eight of 24 runs reported no LCP value at all, and which runs failed changed from one invocation to the next. On the last measurement before the fix, the homepage produced no LCP value in any of its three runs.
When a value did come back, it sometimes named the wrong element. Instead of the page heading, Lighthouse picked a small metadata <span> near the top of the page.
Every heading on the site fades in
The top of each page arrives with a short, staggered rise. The heading, the intro and the stats each start 12 pixels low and fully transparent, then settle into place one after another.
.hor .hor-rise {
animation: hor-rise 0.85s var(--hor-ease) both;
animation-delay: var(--hor-delay, 0ms);
}
@keyframes hor-rise {
from { opacity: 0; transform: translate3d(0, 12px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
animation-fill-mode: both matters here. It holds each element at its from state while it waits for its delay. On the homepage, the last element in the stagger waits 650 ms.
Chrome doesn't count what it can't see
Chrome ignores elements with an opacity of 0 when choosing the LCP element. For content that stays hidden, that's the right call.
Combined with the animation, it meant the heading wasn't an LCP candidate when it first painted. Its first eligible moment came roughly its delay plus the fade after the page first painted.
Everything else on a local load settled in under 400 ms. That moment landed close to the point where Lighthouse decides the page has finished loading. Whether a run reported an LCP value depended on which happened first.
When the heading lost, a small element that didn't animate could win instead. That's how a metadata <span> ended up named as the largest thing on the page.
The fix is one value
@keyframes hor-rise {
from { opacity: 0.01; transform: translate3d(0, 12px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
At 1% opacity, while it's also moving 12 pixels, the heading looks the same as it did at zero. But it's no longer invisible to Chrome, so it's an LCP candidate from its first paint.
Before and after
I ran the same checks straight after the change, three desktop runs per route.
| Before | After | |
|---|---|---|
| Runs with an LCP value | 16 of 24 | 24 of 24 |
| Homepage | 0 of 3 | 3 of 3, median 0.83 s |
| Element Lighthouse named | Sometimes a metadata <span> |
The heading, portrait or intro, every time |
Total Blocking Time had been failing on the same routes. Lighthouse needs the LCP timestamp to calculate it, so the same fix repaired it. Both checks now fail the build, where before they only produced warnings.
What it doesn't fix
The 0.01 start makes the metric honest about which element matters and consistent from run to run. It doesn't make the page faster.
A reader still waits for the stagger to finish before the header is fully visible. If that wait feels too long, the fix is a shorter delay, not a different opacity. That's a design decision, and I've left the stagger as it is for now.
The change also exposed a real problem. With LCP measurable on Lighthouse's mobile preset, the homepage came in at 3.8 and 4.2 seconds across two runs, and the element holding it back is the large portrait image. That's a separate problem, but I couldn't see it until LCP could be measured.
If your hero section fades in
Open Chrome DevTools, record a page load in the Performance panel, and check which element it marks as LCP. If it isn't the element you expect, look at how that element's animation starts.
Starting above-the-fold content at a very low opacity, rather than zero, is a cheap fix. So is leaving the entrance animation off the largest element altogether.
Whichever you choose, leave a comment next to it. Mine says not to tidy the 0.01 back to 0.