← Home
02/02/26 · 12 min

Vite + React Lighthouse: 81 → 100

Vite + React Lighthouse Part 2: 81 → 100 (render-blocking, code-splitting, fonts)

Part 1 got this site to 81 by pre-rendering HTML. Part 2 pushes it to 100 by eliminating what was still dragging the score: render-blocking third-party resources, unused JavaScript, and layout shift.

Results

Production build, Lighthouse mobile:

MetricBefore (post-Part 1)After
Performance81100
FCP2.92 s1.0 s
LCP2.92 s1.9 s
Speed Index2.92 s1.0 s
CLS0.1860
TBT0 ms0 ms
Main thread1.2 s0.5 s
Bootup time0.8 s0.1 s
Render-blocking1,470 ms50 ms
Unused JS125 KiB32 KiB

Removing render-blocking Tailwind and Google Fonts, self-hosting fonts, and code-splitting cut FCP and LCP sharply. CLS hit zero the moment fonts stopped loading late from a third party.

What was still wrong at 81

After SSG: real HTML, TBT at zero. But four things remained:

  • Render-blocking (~1.47 s): Tailwind CDN (147 KB) and Google Fonts CSS. Lighthouse: ~875 ms + ~901 ms.
  • Unused JS (~125 KiB): One large main bundle. Most of it never used on the first route.
  • CLS (0.186): Late-loading fonts shifting layout.
  • Third-party requests: tailwindcss.com, fonts.googleapis.com, fonts.gstatic.com, grainy-gradients.vercel.app.

Three categories of fix: build-time CSS, self-hosted fonts, code splitting.

Fix 1 — Tailwind: CDN → build-time

index.html was loading Tailwind from CDN, an inline tailwind.config script, and a massive <style type="text/tailwindcss"> block. The browser fetched the CDN script, parsed it, then ran Tailwind at runtime — all before first paint.

Removed from index.html: the CDN script tag, Google Fonts link + preconnect, inline tailwind config, the entire text/tailwindcss style block, and the separate CSS link.

Tailwind now runs at build time via PostCSS + tailwindcss v4. Theme, plugins, and utilities live in styles.css, imported from the client entry:

import './fonts.css';
import './styles.css';

CSS is part of the bundle. No third-party fetch, no runtime compilation.

Fix 2 — Self-hosted fonts

Google Fonts: render-blocking stylesheet + late swap = hurts FCP and CLS. Replaced with @fontsource/inter and @fontsource/jetbrains-mono — only the weights I use. fonts.css imports them, loaded before styles.css in the client entry. Fonts are bundled with the app: same origin, no extra request, no late swap. CLS from fonts: gone.

Fix 3 — Code-split routes

Static imports meant every page was in the main bundle. Switched to React.lazy:

const Home = lazy(() => import('./pages/Home'));
const Experience = lazy(() => import('./pages/Experience'));
// ... all routes

<Routes>
  <Route path="/" element={<Home />} />
</Routes>

Initial load now fetches only the current route's chunk. Bootup time: 0.8s → 0.1s.

Fix 4 — Split metadata from post content

The blog list only needs metadata, but constants.ts was importing every post module — full markdown bodies bundled on every page. The split:

  • Client imports POSTS_METADATA from a generated file (metadata only).
  • On demand getPostContent(id) dynamically loads the post module via import.meta.glob.
  • SSR/prerender uses constants-server.ts which still imports full posts for static HTML.

Client pays for metadata + the one post the user opens. SSR still gets full content.

Fix 5 — Vite manualChunks

Separate chunks for vendor (React, ReactDOM, React Router) and markdown (react-markdown, remark-gfm). These change far less often than app code — better caching, smaller main bundle.

Fix 6 — Self-host grain texture

Replaced grainy-gradients.vercel.app/noise.svg with a local public/noise.svg. Same visual, zero third-party requests.

What's left

Lighthouse reports ~32 KiB unused JS and ~50 ms render-blocking — score is 100 because the impact is negligible. Further gains: trim dependencies, lazy-load the markdown bundle only on blog routes, profile hydration with the Performance panel and React Profiler.