Six months ago I shipped a free SaaS — embeddable widgets + free SEO tools, 65+ public pages, 12 locale variants each. The whole thing had to score 100/100/100/100 on Lighthouse (Performance / Accessibility / Best Practices / SEO) on every page. Not because I'm a perfectionist — but because the entire growth strategy depends on Google's Core Web Vitals being a ranking factor.
It worked. The site has been 100/100/100/100 across the indexed surface for ~6 months now, and I've stopped touching the perf code. Here are the four patterns that actually held up in production — past the "I scored 100 on my localhost" stage that 90% of perf blog posts stop at.
Stack disclosure: React 18 SPA, FastAPI backend, MongoDB. No SSR. No Next.js. No CDN edge functions. Vanilla CRA + Helmet. This matters because most "Lighthouse 100" advice you'll read assumes you have Next.js/Astro/Nuxt SSR — these patterns work without that.
Pattern 1 — One inline critical CSS file, ship it via the shell HTML
The single biggest performance killer for a React SPA is the render-blocking CSS request that fires after the JS bundle parses. Even with <link rel="preload"> it tends to add 200-400 ms to LCP on a 3G throttle, which is exactly what Lighthouse simulates.
The fix sounds obvious but everyone gets it wrong: inline the above-the-fold CSS directly into public/index.html, and ship the rest async.
Specifically:
<!-- public/index.html -->
<style>
/* Imported at build time from /src/critical-inline.css */
/* Body skeleton, header, hero block, font-face declarations.
~6-8 KB compressed. No more. */
</style>
<link rel="preload" as="style" href="/static/css/main.[hash].css"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/static/css/main.[hash].css"></noscript>
The /src/critical-inline.css file is 600 lines max — body grid, header, hero section, the first 30vh of every page. Everything below the fold lives in the main bundle and loads after first paint. The preload + onload swap trick keeps it non-render-blocking without breaking JS-disabled crawlers.
What this buys you: LCP drops from ~2.2s → ~0.9s on simulated 4G. That's the difference between a 78 perf score and a 100.
Gotcha: every time you ship a new page that needs different above-the-fold styling, you HAVE to update
critical-inline.css— there's no automation that catches "you added a hero variant but forgot to inline its CSS". I learned this the hard way 3 times. Now every PR has a 30-second manual Lighthouse run on the new page before merging.
Pattern 2 — A /api/_block_html bypass list (= the Lighthouse-403 trap)
This one cost me a week the first time it hit.
If you have ANY rate-limiting / anti-abuse / VPN-block middleware on your backend, PageSpeed Insights' bot fires from random Google Cloud IPs that often look like VPN traffic. The bot gets a 403, the page errors out, and your Lighthouse score craters to ~60 — except only on real Google's PSI, not on your local Chrome devtools run. Pure heisenbug.
The fix is to maintain an explicit bypass list of paths that PSI fetches:
# server.py — `_block_html()` decides whether to block a request
BYPASS_PATHS = {
"/", # homepage shell
"/api/health", # PSI's preflight ping
"/api/badge-data/{kind}", # any badge data the page renders
"/api/embed/{widget}.js", # any embed JS the page tests
"/api/og-image/{path}", # any OG image PSI rendering
# …
}
And the same list in your VPN / proxy / Spamhaus blacklist middleware:
# vpn_proxy.py — same bypass list, second layer
if request.url.path in BYPASS_PATHS:
return await call_next(request) # skip VPN check
Rule I now follow: every time I add a new API endpoint that a public landing page calls, I add it to the bypass list in the same commit. Otherwise PSI scores silently drop on the next deploy and you find out 3 weeks later via Google Search Console.
Why two lists? Because middleware order matters. The Spamhaus check fires BEFORE the rate-limiter, so a path in only one bypass list still gets blocked by the other. Belt + suspenders.
Pattern 3 — A PSI Guard cron that scores every page weekly
You can't manually re-run Lighthouse on 65 pages every week. You won't. So I wrote a PSI Guard — a tiny cron that hits Google's official PageSpeed Insights API for every public URL once a week, persists the 4 scores, and flags any page whose score dropped below 95.
The whole thing is ~200 lines of Python:
async def psi_score(url: str, strategy: str = "mobile"):
"""Hit Google PSI API for a single URL. Returns {perf, a11y, bp, seo}."""
params = {
"url": url,
"key": PSI_API_KEY,
"strategy": strategy,
"category": ["performance", "accessibility", "best-practices", "seo"],
}
r = await httpx_client.get(PSI_ENDPOINT, params=params, timeout=60)
data = r.json()
cats = data["lighthouseResult"]["categories"]
return {
"perf": int(cats["performance"]["score"] * 100),
"a11y": int(cats["accessibility"]["score"] * 100),
"bp": int(cats["best-practices"]["score"] * 100),
"seo": int(cats["seo"]["score"] * 100),
}
async def weekly_sweep(urls: list[str]):
"""Score every URL. Alert if anything dropped below 95."""
sem = asyncio.Semaphore(3) # PSI has a 25k/day budget; this is plenty
async def _one(url):
async with sem:
try:
scores = await psi_score(url)
await db.psi_scores.insert_one({
"url": url, "at": datetime.now(timezone.utc), **scores,
})
if any(v < 95 for v in scores.values()):
logger.warning("PSI drop: %s = %s", url, scores)
except Exception as e:
logger.warning("PSI failed %s: %s", url, e)
await asyncio.gather(*(_one(u) for u in urls))
Wired to a Sunday-night APScheduler job. Every Monday I open the operator dashboard and see a heatmap: 65 rows × 4 columns, all green. If any cell is yellow / red, I have a "before Tuesday's cross-post slot" 30-min budget to dig in.
Cost: the Google PSI API is free up to 25,000 calls/day with a key. I burn ~260 calls/week (65 pages × 4 categories, mobile only). Way under the limit.
This is the pattern that saved me from regressions more than anything else. Every dependency upgrade, every new feature, every "harmless" CSS tweak — the PSI Guard catches it within 7 days. Without this, I'd be flying blind.
Pattern 4 — Route-level code-splitting + zero "convenience" UI libs
The fourth pattern is the one nobody wants to hear: stop importing convenience UI libraries.
I shipped the v0 with Material-UI + chart.js + react-toastify + moment — the usual stack. Lighthouse: 67 perf on the homepage. Bundle: 380 KB gzipped.
I deleted all four. Replaced with:
- MUI → Tailwind + the ShadCN/UI source files (vendored, not installed — I copy in only the components I use). Total: ~40 KB gzipped for the 12 components I actually need.
- chart.js → 200-line custom SVG sparkline component for the 3 places I had charts.
- react-toastify → Sonner (a 6 KB toast library, no animation overhead).
-
moment →
date-fns(tree-shakeable; I import onlyformatDistanceToNow).
Bundle dropped from 380 KB → 142 KB gzipped. Perf jumped from 67 → 96.
Then I added per-route lazy-loading with React.lazy:
// App.js — every non-landing route is dynamically imported
const Explore = lazy(() => import("@/pages/Explore"));
const Milestones = lazy(() => import("@/pages/Milestones"));
const SeoTool = lazy(() => import("@/pages/SeoTool"));
// 30+ routes, each in its own chunk
<Suspense fallback={null}>
<Routes>
<Route path="/" element={<Landing />} /> {/* in main bundle */}
<Route path="/explore" element={<Explore />} /> {/* lazy */}
{/* … */}
</Routes>
</Suspense>
Now the homepage ships ~60 KB of JS. Every other page lazy-loads its own ~20-40 KB chunk on demand. Perf: 96 → 100.
The unintuitive part: shipping FEWER total KB across the whole app is worse than shipping more total KB but split per route. A 142 KB monolithic bundle scores worse than 60 KB landing + 30 KB Explore chunk + 25 KB Milestones chunk… even though the totals are larger. Lighthouse cares about what loads on THIS page, not what's available globally.
What I tried that didn't work
For honesty:
- Service Worker pre-caching — added complexity, broke the "no JS yet" first-paint case, gained nothing on Lighthouse (it doesn't simulate a returning visitor).
-
fetchpriority="high"on hero images — minor LCP win (~80 ms) but the variance between runs swallowed the gain. Not worth the cognitive overhead. - Brotli compression on the backend — already done at the ingress layer (Kubernetes nginx). Doing it twice adds latency, doesn't reduce bytes.
-
Prerendered HTML for crawlers via a separate path — works for Googlebot, but Bing + DuckDuckGo handle React SPAs better than they used to and the prerender pipeline became maintenance debt. I kept the SPA-only approach and just made sure every page's
<title>and<meta>tags are server-renderable via the staticindex.htmlshell.
TL;DR — the 4 patterns
-
Inline the critical CSS (~6-8 KB) directly into
public/index.html. Async-load the rest. - Maintain a bypass list for every API endpoint a public page fetches — in BOTH your rate-limiter and your VPN-block middleware. PSI bots have weird IPs.
- Run a weekly PSI Guard cron that scores every public URL via the Google PSI API and flags drops below 95. ~200 lines of Python, free under the 25k/day limit.
- Delete every convenience UI library. Vendor ShadCN source files. Lazy-load every route. Bundle target: ≤60 KB per page.
Six months in production, 100/100/100/100 across 65+ pages, zero regressions caught. Hope this helps.
Discuss on Hacker News · Live demo of the patterns: feed-pulse.com
Top comments (0)