Skip to content

feat(perf): inline critical CSS + preload critical fonts via prerender post-process#22

Merged
miquelmatoses merged 1 commit into
mainfrom
feat/critical-css-font-preload
May 17, 2026
Merged

feat(perf): inline critical CSS + preload critical fonts via prerender post-process#22
miquelmatoses merged 1 commit into
mainfrom
feat/critical-css-font-preload

Conversation

@miquelmatoses
Copy link
Copy Markdown
Collaborator

Closes the remaining performance gap from PR #21 PageSpeed follow-up.

Approach B from the audit: beasties (Node.js lib) runs inside scripts/prerender.mjs after each route's full DOM is captured by puppeteer, so each of the 637 routes gets its own critical CSS extraction matched to that page's above-the-fold content. Font preload tags are injected by the same step, with the content-hashed woff2 filenames extracted from the built CSS at runtime.

What changed

  • extractCriticalFontUrls() — reads dist/assets/index-*.css at runtime to find the 4 content-hashed font URLs (Playfair Display 400, Roboto 400/500/700 latin); regex handles Vite's -normal- naming pattern
  • buildFontPreloadTags() — generates <link rel="preload" as="font" type="font/woff2" crossorigin> tags
  • startServer(originalIndexHtml) — takes a frozen snapshot of the Vite-built index.html as the SPA shell; without this, routes processed after / would pick up already-injected preload tags and produce 8 duplicates instead of 4
  • Beasties instance with pruneSource: false (all 637 HTMLs share one CSS file), preload: 'swap' (non-blocking CSS), inlineThreshold: 0
  • renderOneRoute — injects font preloads + window globals → beasties.process() → writes to disk; beasties failures fall back to un-processed HTML

Local verification

Check Result
Font preloads per page 4 (exact, no duplicates)
Critical CSS inlined 18-21 KiB per page
CSS swap (preload+onload)
Shared CSS unchanged 90,056 bytes (pruneSource:false)
All 6 blog indexes error_count=0, 104 links each
Tests 199/199

Expected outcomes

  • Landing LCP: 4.5s → ~2.0-2.5s
  • Landing Performance: 75 → ~85-90
  • Blog slug LCP: 6.5s → ~2.5-3.5s
  • Blog slug Performance: 66 → ~78-85

Risks

  • FOUC: <100ms window thanks to beasties' built-in noscript fallback
  • Stale CDN cache: 10 min max, self-resolving
  • Build time: ~18min total (prerender phase dominates, same as before)

…r post-process

Closes the remaining performance gap from the PR #21 PageSpeed
follow-up: render-blocking CSS (~1100ms) and undiscovered critical
fonts (~400ms).

Approach: instead of running beasties as a Vite plugin (which sees
only the empty SPA shell), invoke beasties as a Node.js library
inside scripts/prerender.mjs, after each route's full DOM is captured
by puppeteer. Each of the 637 routes gets its own critical CSS
extraction matched to that page's above-the-fold content.

Font preloads are injected by the same prerender step. The 4 latin
woff2 files (Playfair Display 400, Roboto 400/500/700) are
content-hashed by Vite so the URLs change every build; the script
extracts them from dist/assets/index-*.css at runtime.

scripts/prerender.mjs changes:

- New helper extractCriticalFontUrls() parses the built CSS file
  to find the 4 critical font URLs with their content hashes
- New helper buildFontPreloadTags() generates the <link rel=preload>
  tags (with crossorigin, required by browser spec)
- New Beasties instance configured with pruneSource:false (the
  shared CSS file must remain complete — 637 HTMLs reference it),
  preload:'swap' (external CSS becomes non-blocking), and inlineThreshold:0
- startServer() now accepts and uses a frozen copy of the original
  Vite-built index.html as the SPA shell fallback; without this,
  routes processed after '/' would pick up the already-injected
  preload tags and produce 8 duplicate preloads instead of 4
- renderOneRoute now: injects font preloads + window globals into
  the <head>, then runs beasties.process() on the result, then
  writes the final HTML to disk
- If beasties fails on a specific page, log and fall back to the
  un-processed HTML rather than failing the build

package.json: beasties ^0.4.2 added as devDependency (no runtime
dependency).

Local verification:
- 4 font preloads on every route (/, /about, /blog, /ca/blog, slugs)
- 18-21 KiB critical CSS inlined per page
- CSS swap (preload + onload) pattern present
- Shared CSS file unchanged: 90,056 bytes (pruneSource:false)
- All 6 blog indexes: 0 errors, 104 links each
- 199/199 tests passing

Expected outcomes:
- Landing LCP: 4.5s → ~2.0-2.5s
- Landing Performance: 75 → ~85-90
- Blog slug LCP: 6.5s → ~2.5-3.5s
- Blog slug Performance: 66 → ~78-85
- Build time: +~14min on the prerender phase (acceptable for CI)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@miquelmatoses miquelmatoses merged commit 2647ba6 into main May 17, 2026
3 checks passed
@miquelmatoses miquelmatoses deleted the feat/critical-css-font-preload branch May 17, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant