Skip to content

Preload only the H1 and body faces - #1139

Merged
blove merged 1 commit into
mainfrom
blove/font-preload-diet
Sep 23, 2026
Merged

blove merged 1 commit into
mainfrom
blove/font-preload-diet

Conversation

@blove

@blove blove commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Stops preloading the two fonts the hero doesn't need, which were racing the LCP image on phones.

Why fonts, when the question was JavaScript

This came out of digging into the JS that blocks LCP. Module-level attribution of the homepage's 236KB (gz) of required scripts, from production source maps:

Group KB Share
React + Next.js runtime ~121 51%
posthog-js ~59 25% — parked until the beacon data is in
Our app + lib code ~38 16% — spread thin, largest file 3.2KB

JavaScript is fetched at Low priority, yet blocking all of it on prod saved ~1,040ms. So the CDN splits bandwidth rather than honoring priority, and a KB of font costs as much as a KB of JS. Measured on prod (Lighthouse mobile, devtools throttling, HTTP/2, 5 runs each, */ingest/* blocked):

Blocked LCP Δ
nothing 3,651 ms
Archivo italic (38.5KB) 3,423 ms −228
JetBrains Mono (39.7KB) 3,428 ms −224
PostHog (61KB) 3,454 ms −197

Each of those two fonts costs more LCP than PostHog does, with no analytics trade-off.

Change

  • JetBrains Mono: preload: false, plus a real monospace fallback stack. next/font's generated fallback for it was local(Arial) — a proportional font — which would have misaligned code on docs pages while the face loads.
  • Archivo italic: split into its own next/font instance with preload: false. next/font preloads per instance, so this is the only way to skip just the italic. Both instances register the real family name Archivo (checked in the built CSS), so the browser merges them: every existing font-style: italic still gets the true italic, and no CSS changes.
  • Archivo normal and Archivo Black stay preloaded.

What I deliberately did not ship

Un-preloading Archivo normal too was built and measured. It's the biggest gain (local LCP 1,803ms), but the hero subhead wraps to 2 lines in the fallback and 3 in Archivo, so the swap pushed the CTAs and the whole demo down: CLS 0.206 on a 375×812 phone, in every run. That's over the 0.1 rollback line set before starting. Recovering it would need the reflow solved first, for example by reserving the subhead's height. That's a separate decision.

Lighthouse reported that variant's CLS as 0.001: its trace missed the swap. The 0.206 comes from a layout-shift observer spanning the whole font swap.

Measured (local production build, directional)

Local serves HTTP/1.1, so these are relative only; prod is re-measured after deploy.

Variant Home LCP CLS across the swap (home / docs / blog w/ italics)
Before 2,429 ms 0 / — / —
This PR 2,176 ms 0 / 0 / 0.0003
Full diet (not shipped) 1,803 ms 0.206 / 0 / —

Guards (e2e/home-bundle.spec.ts)

  • Exactly two preloads — Archivo Black normal and Archivo normal, each resolved to its @font-face via the CSSOM, so the wrong face can't pass on count alone. It reads the live DOM because next dev injects preloads at hydration, while production writes them into the HTML.
  • The unpreloaded italic still merges into Archivo: document.fonts.load('italic 400 16px Archivo') must find an italic face. This catches a future next/font that hashes family names again, which would silently make every italic faux.
  • Mono never falls back to a proportional font.

All three were mutation-tested. Each regression — italic back in the preloaded instance, italic instance removed, mono back on the Arial fallback — fails only its own guard.

Verification

  • nx test website, nx lint website, nx build website
  • Full website e2e: 169 passed, 0 failed, including every geometry spec
  • Guards in dev mode and against a production build (5/5 each)
  • After deploy: prod LCP + CLS, 5 runs, */ingest/* blocked. Rollback if LCP doesn't improve or CLS > 0.1.

🤖 Generated with Claude Code

On a throttled phone the CDN splits bandwidth between concurrent requests
rather than honoring priority, so every preloaded font races the hero
poster, the LCP element. Measured on production (Lighthouse mobile, devtools
throttling), blocking Archivo italic moved LCP -228ms and JetBrains Mono
-224ms, each more than PostHog's whole library.

- JetBrains Mono is no longer preloaded, and gets a real monospace fallback
  stack. next/font's generated fallback for it was local(Arial), a
  proportional font that would misalign code while the face loads.
- Archivo is split into two next/font instances so its italic can skip the
  preload (next/font preloads per instance). Both register the real family
  name, Archivo, so the browser merges them and every existing
  font-style: italic still selects the true italic. Nothing else changes.
- Archivo normal STAYS preloaded. Un-preloading it was measured: the hero
  subhead wraps to two lines in the fallback and three in Archivo, so the
  swap pushed the CTAs and demo down, CLS 0.206 on a 375x812 phone.

Local production build, devtools throttling (HTTP/1.1, directional): home
LCP 2,429 -> 2,176ms. CLS across the font swap, measured with a
layout-shift observer: home 0, docs 0, a blog post with early italics
0.0003.

e2e/home-bundle.spec.ts now asserts exactly these two preloads by family and
style, that the unpreloaded italic still merges into Archivo, and that mono
never falls back to a proportional font. Each was mutation-tested and fails
alone on its own regression.
@blove
blove enabled auto-merge (squash) September 23, 2026 15:32
@vercel

vercel Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 23, 2026 3:35pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 4109930 into main Sep 23, 2026
32 of 33 checks passed
blove added a commit that referenced this pull request Sep 23, 2026
The Inter comment in layout.tsx and the FONTS note in ui.css both said
next/font registers families under a hashed name. That was true of older
next/font; next/font 16 registers the real name — the built CSS emits
`@font-face{font-family:Inter}` and `--font-diagram:"Inter","Inter Fallback"`.
#1139's italic split depends on exactly that.

The conclusions still hold, so behaviour is unchanged: theme.css's
`Inter, system-ui` stack only names the family, and the font file exists only
because next/font loads it. Remove the loader and diagrams still fall back to
system-ui. Only the stated mechanism was wrong.
blove added a commit that referenced this pull request Sep 23, 2026
The Inter comment in layout.tsx and the FONTS note in ui.css both said
next/font registers families under a hashed name. That was true of older
next/font; next/font 16 registers the real name — the built CSS emits
`@font-face{font-family:Inter}` and `--font-diagram:"Inter","Inter Fallback"`.
#1139's italic split depends on exactly that.

The conclusions still hold, so behaviour is unchanged: theme.css's
`Inter, system-ui` stack only names the family, and the font file exists only
because next/font loads it. Remove the loader and diagrams still fall back to
system-ui. Only the stated mechanism was wrong.
@blove
blove deleted the blove/font-preload-diet branch September 23, 2026 20:53

This branch was successfully deployed

1 active deployment
Preview – threadplane — 83afdb21 Deployed Sep 23, 2026 by vercel[bot]
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