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:
| Metric | Before (post-Part 1) | After |
|---|---|---|
| Performance | 81 | 100 |
| FCP | 2.92 s | 1.0 s |
| LCP | 2.92 s | 1.9 s |
| Speed Index | 2.92 s | 1.0 s |
| CLS | 0.186 | 0 |
| TBT | 0 ms | 0 ms |
| Main thread | 1.2 s | 0.5 s |
| Bootup time | 0.8 s | 0.1 s |
| Render-blocking | 1,470 ms | 50 ms |
| Unused JS | 125 KiB | 32 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_METADATAfrom a generated file (metadata only). - On demand
getPostContent(id)dynamically loads the post module viaimport.meta.glob. - SSR/prerender uses
constants-server.tswhich 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.