DEV Community

Eric Will
Eric Will

Posted on

How I keep Lighthouse 100/100/100/100 on every public page (the 4 patterns that survived production)

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>
Enter fullscreen mode Exit fullscreen mode

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
    # …
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 only formatDistanceToNow).

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>
Enter fullscreen mode Exit fullscreen mode

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 static index.html shell.

TL;DR — the 4 patterns

  1. Inline the critical CSS (~6-8 KB) directly into public/index.html. Async-load the rest.
  2. 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.
  3. 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.
  4. 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)