lans.cloud is ~65 free single-purpose web tools — classroom timers, dice rollers, a Dragon Ball power level calculator. Fast pages are the whole point: they're static, they target long-tail searches, and Core Web Vitals is a ranking input I actually care about. So I run PageSpeed Insights across the site regularly.
Today's sweep — the first time I ran it across every page instead of a 14-page sample — came back looking like a disaster. And I nearly "fixed" it. This is the story of why I didn't, because the lesson generalizes: a single PSI run will lie to you, and the shape of the lie is specific enough to recognize.
The scary number
I fed all 81 canonical pages to my sweep script (npm run psi with an explicit path list) and got a clean bimodal split:
path perf FCPs LCPs TBTms CLS
/random-workout-generator 66 1.2 6.2 381 0.000
/for-schools 70 1.2 5.7 312 0.000
/sibling-name-matcher 70 1.4 6.0 247 0.000
/classroom-birthday-chart-generator 71 1.2 5.6 342 0.000
...
/jumpscare-prank 72 1.2 5.8 245 0.000
...
/random-name-picker 99 1.1 1.9 67 0.000
/roman-numeral-converter 99 1.1 2.0 108 0.000
/case-converter 99 1.1 1.8 120 0.000
cohort median: 96
Roughly 30 pages at 66–80 with LCP of 5.2–6.2s, and 50 pages at 95–99 with LCP under 3s. Same shared layout, same CSS, same JS bundle. CLS was a flat 0.000 everywhere, FCP was ~1.1s almost everywhere — so this was entirely an LCP story: ~2s on the fast half, ~5.5s on the slow half.
The obvious reading: something regressed, and it's hitting half the site. My brain immediately reached for the culprit. Months ago I'd fixed exactly this symptom — a slow-club/fast-club LCP split — by turning on Next.js's experimental.inlineCss to kill two render-blocking stylesheet fetches. Had that flag silently stopped working on an upgrade? The pattern was identical.
That's the trap. The pattern being familiar is exactly what makes you skip the verification step.
The tell: a page that scored 100 last week is now "broken"
Before changing anything, I cross-checked against my last PSI report from five days earlier. And there it was:
/test-grade-calculator: 100 on 2026-07-09 → 77 today./: 95–99 on 2026-07-09 → 75 today.
Nothing shipped to those pages in between. A static page does not lose 23 points of performance while sitting untouched on disk. When the same page swings that far with no change, you're not looking at a regression — you're looking at your measurement instrument.
PageSpeed Insights runs Lighthouse on shared Google infrastructure. Those runners are under variable load, and they'll swing an absolute score by 20+ points depending on how busy they are when your request lands. My own sweep script even carries a comment reminding me of this — which I'd cheerfully ignored the moment I saw a scary table:
// READ RELATIVELY: Google's shared runners swing absolute scores by 20+
// points with load. A page is only a problem if it scores well below the
// SAME-RUN cohort. Until AdSense approval, every page also carries the
// unfilled-ad-script tax from Google's own adsbygoogle chain.
But "runner load" usually explains a uniform dip — the whole cohort drifts down together. This was bimodal within one run: half green, half red. That's what kept me suspicious enough to actually test it rather than wave it away.
The test: hit the slow pages again, warm
Here's the key move. If the slow scores are real, re-running the same page gives you the same bad number. If they're a cold-cache or cold-runner artifact, a second hit — now warm — jumps to green.
So I re-probed just the slow club:
node scripts/psi-sweep.mjs /random-workout-generator /for-schools \
/sibling-name-matcher /classroom-birthday-chart-generator \
/chore-chart-generator /multiplication-timed-test /test-grade-calculator /
Eight of the twelve pages I re-checked jumped straight to green:
| Page | cold sweep | warm re-hit |
|---|---|---|
| / | 75 | 94 |
| /for-schools | 70 | 97 |
| /sibling-name-matcher | 70 | 98 |
| /chore-chart-generator | 74 | 98 |
| /multiplication-timed-test | 73 | 99 |
| /test-grade-calculator | 77 | 99 |
LCP collapsed from ~5.7s to ~2.0s on the warm hit. Not "improved" — collapsed, because nothing about the page changed in ninety seconds. The first render lost a race (cold origin connection, cold runner); the second didn't.
The stragglers — and why you keep going
Four pages stayed slow on that second hit, including /jumpscare-prank — which happens to be my highest-traffic page, so I wasn't going to hand-wave it. Two consecutive runs at ~78–80 with LCP stuck at 5.4s. That could be a real heavy LCP element (a big hero image that won't warm away).
So I dug: what's the actual LCP element? I pulled the largest-contentful-paint-element audit straight from the PSI API. And by the time that probe ran — a few hits later — the page returned 98, LCP 2.1s. The last straggler, /dragon-ball-fusion-name-generator, settled at 100, LCP 1.6s.
They weren't stubborn. They were just the pages that happened to land cold on the second round too. Enough warm hits and every single one converged to 94–100.
The lesson inside the lesson: don't stop at one re-hit. A single warm re-probe rescues most pages but can leave a couple looking guilty by sheer bad luck. If you're going to trust the tool, hit the survivors a few more times before you escalate to "this one's actually broken."
The root cause, confirmed in the build output
I closed the loop by proving there was no per-request cost to optimize in the first place. Are these pages even rendered on demand? One look at the build manifest:
grep -rn "force-dynamic\|export const revalidate" app/ # → nothing
node -e 'console.log(Object.keys(require("./.next/prerender-manifest.json").routes).length)'
# → 100
No force-dynamic, no revalidate — all 100 routes are statically prerendered. The HTML is written at build time and served from disk. There is no SSR render path to speed up. The cold/warm swing is entirely Google's runner load plus cold-connection latency to the origin, riding on top of a small fixed tax from the unfilled AdSense script every page currently carries. None of it is a code defect, and none of it reflects what a real user on a warm CDN path experiences.
Every page on the site scores 94–100 warm, LCP 1.6–2.9s, CLS 0.000, with zero opportunities above 100ms. The "30 broken pages" were 30 cold measurements.
The method, so I don't fall for it next time
I wrote the takeaway into the report itself, because the next full sweep will produce the same scary bimodal table and future-me will feel the same urge to fix it:
- A full cold sweep always splits bimodally. Whichever pages land on a busy runner or a cold connection read 15–25 points low. This is the default, not a signal.
- Judge a page by a warm re-hit, against the same-run cohort median — never by a single absolute number. My script prints the median for exactly this reason.
- A page is a real problem only if it stays >15 below the median across repeated warm hits. One bad hit means nothing; three do.
- Cross-check against history. A static page that dropped 20+ points since last week with no deploy is your instrument talking, not your code.
- Confirm there's even a lever. If the routes are statically prerendered, there's no per-request render cost — so a "slow" cold number has nothing on-page to optimize anyway.
The uncomfortable part is that steps 1–4 all point the same way before you write a line of code, and I still almost skipped them because the failure pattern matched a real bug I'd fixed before. Familiarity is a lousy substitute for a second measurement.
The best outcome of a performance investigation is sometimes discovering there was nothing to fix — and being able to prove it, so you don't spend a day optimizing a runner's bad mood.