~/connor.meador
← back to build log
8 min read

The hero fade that broke LCP (and how I actually diagnosed it)

A fade-in entrance kept the hero from ever being the early LCP candidate, so the metric latched onto a font-swap repaint at ~3.3s. The diagnosis method, the fix, the rule it became — and the measurement that still doesn’t add up.

performanceLCPdebuggingCore Web Vitalswar story

In June I ran a launch-prep audit on a client e-commerce build. The PageSpeed Insights numbers looked almost fine: 26 lab runs across the site, performance ≥90 on 24 of them. But the home page kept oscillating between 89 and 92 on mobile, and checkout sat at 87. "Almost fine" on the two pages that matter most is not fine.

The score wasn't the story. The story was what was being measured — and it took a custom trace to see it.

The trap: a fade-in that hides your hero from LCP

The home hero had a fade-in utility class — an opacity: 0 → 1 entrance animation on the wrapper around the H1 and the banner image. It looked great. It also kept both elements from ever being the *early* Largest Contentful Paint candidate.

Chrome's LCP algorithm only records an element as a candidate once it has actually painted meaningful, sized content. An element animating in from opacity: 0 isn't "painted" at that moment. So the browser went looking for the largest thing that *did* paint immediately — and, per the audit's conclusion, landed on the header wordmark, whose own paint was gated behind a webfont swap. That font-swap repaint became the LCP event, at roughly 3.3 seconds on throttled mobile.

Nothing was slow, exactly. The metric was just measuring the wrong element — because the design had hidden the right one.

Reading the candidate list instead of the score

Lighthouse gives you one number and one element. When the number oscillates run-to-run, you want the whole sequence. I wrote a small tracer (lcp-trace.mjs) instead of re-running PSI a tenth time:

  • Playwright driving a real mobile viewport (412×823, DPR 1.75, mobile UA) with raw CDP throttling — 1.6 Mbps down, 150 ms RTT, 4× CPU slowdown, mirroring Lighthouse's mobile emulation.
  • An injected PerformanceObserver({ type: 'largest-contentful-paint', buffered: true }) logging every candidate: timestamp, size, tag, class, text or src.
  • A listener on document.fonts "loadingdone" events, timestamped and correlated against candidate changes.

That last correlation is the payoff: you can watch the LCP winner change hands and see exactly which font finished loading right before it happened. A second script cross-checked against the real PSI v5 API — the LCP element's DOM snippet plus the phase breakdown (TTFB / load delay / load time / render delay) — so the local trace and Google's scored model could disagree with each other in the open.

The tracer surfaced a second, unrelated trap on checkout: the page server-rendered a blank min-h-[50vh] shell and hydrated the content in later — so LCP on that route was gated on client-side hydration, ~3 seconds in. Two different mechanisms, same effect: the element users actually look at wasn't the element being measured.

The fix, and what the fix honestly did

Both fixes shipped the same day. The hero: remove the fade from the wrapper entirely — H1 and banner image paint statically at first paint. (Decorative elements can still fade; the LCP element can't start at opacity: 0.) The checkout: replace the blank hydration shell with a permanent server-painted heading, keeping the later client-rendered text visually smaller so it never re-wins LCP.

Now the part most write-ups leave out. The audit's spot-check claimed home mobile went 89 → 95 ("median of 3"). The full 26-run sweep on the *same fixed deploy* recorded home mobile at 92 — and the measured LCP was still 3.30s. Those numbers sit in the same report, unreconciled. Lab variance, a candidate that still resolves late under throttling, or something unfixed — the sources don't say, and I'm not going to pretend they do. The verified claim is narrower than the satisfying one: the score improved, the structural trap was real and removed, and the measurement still has an open question in it. That open question is on my list, not under the rug.

All of this is lab data — simulated throttling, not field data from real users. Worth saying out loud, because the two get conflated constantly.

From incident to rule

The lesson didn't stay in a report. It's now a hard constraint in my component library and review rules: heroes render statically — a mount fade is permitted only for non-LCP decoration, capped at 400ms, honoring prefers-reduced-motion, and never starting the hero H1 or hero image at opacity: 0. The shared HeroSection component encodes it structurally (the fade simply isn't available on the elements that matter), and the design-review rubric fails any page that reintroduces it. Four days after the fix, the rule was in the library; every site since inherits it.

That's the actual return on a war story: not the one-time score bump, but the class of bug you can never ship again.

Takeaways

  • An opacity: 0 entrance on your hero delays its LCP candidacy past the first paint — the metric latches onto whatever painted early instead, often something font-gated.
  • A blank SSR shell that hydrates in content gates LCP on your JS bundle. Server-paint the largest thing.
  • When a score oscillates, stop reading the score. Log the full LCP candidate sequence and correlate it with font loads — twenty lines of PerformanceObserver beats ten more Lighthouse runs.
  • Report the numbers that survived scrutiny, including the ones that don't flatter the fix.

I'm looking for a remote full-time role — AI automation, integration, or full-stack. If your team wants someone who debugs like this, I reply within a day.

// let’s talk

Open to remote full-time roles (U.S., Eastern Time) — AI automation, integration, or full-stack — available now. If your team is hiring, I reply personally within 24 hours on weekdays.

Get in touch →