DEV Community

Cover image for Google said my fastest pages were slow: a PageSpeed mystery in three clues
Sonny
Sonny

Posted on

Google said my fastest pages were slow: a PageSpeed mystery in three clues

My site lans.cloud is 51 static, client-side tools served from one small container. Local Lighthouse scored every page 90+. So when I finally ran the same pages through Google's real PageSpeed Insights (PSI) application programming interface (API), I expected a victory lap.

Instead I got this:

Page Local Lighthouse PSI (Google's infra) PSI Largest Contentful Paint
/classroom-scoreboard 90 100 1.4 s
/my-data 92 100 1.7 s
/spin-the-wheel 93 76 5.6 s
/classroom-timer 95 79 5.2 s
/ (homepage) 92 70 5.6 s

Five pages at 97–100. Five pages at 70–79. Nothing in between — and my local runs had shown no such split. Every failing page failed the same way: a Largest Contentful Paint (LCP) of ~5.5 seconds.

Statically prerendered pages. Same layout, same Cascading Style Sheets (CSS), same fonts. A 5.5-second LCP made no sense.

Clue 1: the "slow" element was innocent

PSI's JSON (JavaScript Object Notation) response tells you which element was the LCP. On every slow page it was… the header description. A server-rendered <p>. Plain text, present in the initial HTML, no image, no font trickery.

The LCP breakdown said: time to first byte, 14 ms. Element render delay, 2,291 ms.

Static text that the browser has from byte one, painting 2.3 seconds late. The element wasn't slow — something was delaying paint itself. Which led to the actually useful query: compare observedLargestContentfulPaint with observedFirstContentfulPaint — the First Contentful Paint (FCP) — in the raw response.

They were identical on every page. Fast pages: LCP = FCP = ~140 ms. Slow pages: LCP = FCP = ~2,300 ms.

So there was never an LCP problem. There was a first paint problem, and the LCP element was just standing nearby when it happened.

Clue 2: it only happened on slow hardware

From my own machine, real Chrome painted every one of those pages in 100–250 ms. Fast club, slow club, no difference.

That's the trap with performance work: my hardware is fast, Google's test runners are deliberately slow. Whatever was delaying paint scaled with processor speed — and disappeared entirely when processing power was cheap.

I diffed everything between a slow page and a fast page: <head> contents, stylesheet lists, script tags, HyperText Markup Language (HTML) byte offsets of the visible text, pending Suspense boundaries. Nearly identical. The slow pages had two extra route-specific JavaScript chunks. That was the whole difference.

Clue 3: the race

Here's the mechanism, and I think it's underdocumented:

Async scripts don't block parsing — but evaluating them blocks the main thread, and paint needs the main thread. On fast hardware, first paint happens long before any script arrives. On a slow machine with fast-arriving scripts, it's a race: if a chunk arrives and starts evaluating before the browser gets around to its first paint, paint waits.

My five slow pages had marginally more JavaScript. Not dramatically more — just enough to tip the race on Google's runners. Five pages painted at 140 ms; five painted after ~2 s of script evaluation.

Then the second mechanism kicks in: PSI uses simulated throttling. It records an unthrottled trace and then a model ("Lantern") estimates what the metrics would be on a slow device and network. The model saw "first paint waited for JavaScript" in the trace and, applying its throttling math to that dependency, projected LCP at 5.5 seconds. The 2.3 s race loss became a 5.5 s headline number.

One trace where paint loses a race by a hair → a model that treats the loss as a structural dependency → a 30-point score gap. That's how five identical-architecture pages split into two clean clubs.

The fix was one line

If paint must win a race, shorten paint's track. My CSS totals ~19 kilobytes, but it shipped as two render-blocking stylesheet requests — the largest fixed cost on the paint path. Next.js has a flag for exactly this:

// next.config.ts
const nextConfig: NextConfig = {
  output: 'standalone',
  experimental: {
    inlineCss: true, // CSS ships inside the HTML — zero blocking fetches
  },
};
Enter fullscreen mode Exit fullscreen mode

With the CSS inlined into the HTML, the browser can paint the moment it has parsed the (single) response. No fetches to wait for, nothing for script evaluation to cut in front of.

Same day, same API:

Page Before After
/spin-the-wheel 76 100
/classroom-timer 79 92
/ (homepage) 70 95–99
/random-name-picker 74 96
/classroom-scoreboard (control) 100 99

One caveat from the trenches: the first run right after deploying read lower (68) — cold caches on Google's side. Re-run before you panic; judge trends, not single runs. And inlining CSS is a trade: it's the right call when your CSS is small (mine is ~19 kilobytes and identical across pages); with hundreds of KB of styles you'd be bloating every HTML response instead.

Epilogue: the ghost came back — and proved the point

A week later I probed again on an ordinary evening and nearly relapsed into panic: the homepage read 74. But this time I had a control page — one I hadn't deployed to or changed in any way. It read 95 in the afternoon, 78 an hour after that, 73 by night. Same page, same URL, zero deploys. Back-to-back runs of one page gave first paints of 1.2 s and 3.0 s. And the fingerprint from Clue 1 was back on every page: observed LCP exactly equal to observed FCP, with a two-second "element render delay" pinned on an innocent paragraph.

That's not a website changing three times in an evening — that's shared test runners changing under load. So two rules got added to my monitoring: probe at a fixed quiet hour (mine runs Monday mornings), and always probe a control page you didn't touch. If the control moves with your page, you're measuring Google's infrastructure, not your deploy.

What I actually learned

  • observedLargestContentfulPaint vs largestContentfulPaint in the PSI response is the single most diagnostic comparison available. Observed is what happened on their hardware; the other is the model's projection. When they diverge wildly, you're debugging the model's inputs, not your page.
  • LCP problems are sometimes paint problems, and paint problems are sometimes races. If observed LCP equals observed FCP, stop staring at the LCP element.
  • Local Lighthouse and PSI are different instruments. My datacenter runner was kinder to the slow pages and harsher on the fast ones — systematically. PSI is the number that correlates with how Google sees you; optimize against it.
  • Get the free API key. The keyless PSI endpoint shares one global quota with the entire internet and is permanently exhausted — I verified by firing at the exact moment of quota reset and still getting a 429. With a key it's 25,000 requests per day, which turned "check PSI sometimes" into a weekly probe in my monitoring.

This is the sequel to my earlier Cumulative Layout Shift (CLS) hunt — same site, same lesson wearing a different costume: the metric that's screaming is rarely the thing that's broken.

The site is lans.cloud — 51 free browser tools, all static, all measured. Happy to answer anything about the setup.

Top comments (0)