Six days ago I wrote about next/font/google breaking on an ampersand, which is how Notifio ended up with two woff2 files committed into the repo. The setup it left behind:
// Self-hosted latin variable fonts (Google Fonts). next/font/google in Turbopack
// fails the build whenever Google serves a font URL containing "&".
const spaceGrotesk = localFont({
src: "./fonts/space-grotesk-latin.woff2",
variable: "--font-space-grotesk",
weight: "300 700",
display: "swap",
preload: true,
});
Two of those, plus Geist Mono from its npm package, all three registered on <html> in the root layout. preload: true on both local ones, which is also the default. So all three preload on every page. I believed that until I measured it this morning.
The measurement
$ for u in / /download /pricing /help /alerts /compare /guides /about /terms; do
n=$(curl -s "https://notifio.app$u" | grep -o '<link[^>]*woff2[^>]*>' | wc -l)
echo "$u $n"
done
| Page |
<link rel="preload" as="font"> in the HTML |
|---|---|
/ |
0 |
/download |
0 |
/pricing |
0 |
/help |
3 |
/alerts |
3 |
/compare |
3 |
/guides |
3 |
/about |
3 |
/terms |
3 |
Six pages get this, in the head, before anything else:
<link rel="preload" href="/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2" as="font" crossorigin="" type="font/woff2"/>
<link rel="preload" href="/_next/static/media/dm_sans_latin-s.p.2_mbdogr7ni8i.woff2" as="font" crossorigin="" type="font/woff2"/>
<link rel="preload" href="/_next/static/media/space_grotesk_latin-s.p.2cyn07wtgehh0.woff2" as="font" crossorigin="" type="font/woff2"/>
Three get nothing. And the three that get nothing are the home page, the pricing page and the download page, which are the first page most people see, the page where they decide, and the page where they act.
They are also the only three pages that read the request
That is not a coincidence, and the reason is in a module I wrote a comment on weeks ago:
/**
* ## The cost
*
* Reading the request makes the calling page render per request, so the pages
* that show prices give up static rendering and must be marked
* `force-dynamic`. That is the intended trade: a page that renders instantly
* and shows the wrong number is worse than one that renders correctly.
*/
export async function getDisplayCurrency(): Promise<DisplayCurrency> {
const headerList = await headers();
return currencyForVisitor(headerList.get("x-vercel-ip-country"), headerList.get("user-agent"));
}
Three pages show a price, so three pages call that, so three pages are force-dynamic. Grep agrees:
$ grep -rln "force-dynamic" src/app --include="*.tsx"
src/app/page.tsx
src/app/download/page.tsx
src/app/pricing/page.tsx
Three out of three, and six out of six the other way. I did not predict this and I would not have found it by reading the code, because nothing in the font setup mentions rendering mode and nothing in the currency module mentions fonts.
The hints are not gone, they have moved. On the home page the same three files appear inside the streamed payload instead:
:HL[\"/_next/static/media/GeistMono_Variable.p.3ms9vq719j3f8.woff2\",\"font\",{\"crossOrigin\":\"\",\"type\":\"font/woff2\"}]
That is a resource hint travelling as data in the React payload, applied by the client once it has parsed it, rather than a tag in the head that the browser's preload scanner can act on before it has even finished reading the document. Same three fonts, later discovery.
What I actually know, and what I am inferring
I measured tags, not timings. I have not sat with a filmstrip and counted frames on a throttled connection, so I am not going to put a millisecond number on this.
What follows from the setup, though, without needing a measurement: all three families are display: swap, which means a visitor whose cache is cold renders the page in a fallback face and then re-renders it in ours. The whole point of a head preload is to shorten that window. On the six static pages it is as short as Next can make it. On the three that matter most commercially it is not.
Two things take the edge off it. The font files are served cache-control: public,max-age=31536000,immutable, so this is a first-visit cost only, and anyone who lands on a guide or an alerts page before the pricing page has the fonts already. And the stylesheet in the head does carry the @font-face rules, so the browser does find the fonts on its own; it just finds them after it has built enough of the CSSOM to know it needs them.
Three options, and the cheap one is embarrassing
- Render the price on the client instead. Rejected already, in writing, in the same comment: the page would paint one number and swap it, a visible flicker on exactly the figure the visitor came to read.
- Accept it. Defensible. It is one font swap, once, on a first visit, on a site whose next click is a 600MB installer download.
-
Emit the three
<link rel="preload">tags by hand in the root layout. About six lines, no build step, works regardless of rendering mode, and the hrefs are the fingerprinted ones thatnext/fontalready exposes on each font object.
Option 3 is the answer, and the only reason it was not already there is that next/font is supposed to be doing exactly that job, and on six of nine pages it is.
While I am in there, one more thing the measurement showed. The sizes of the three files, as served:
GeistMono_Variable.p.3ms9vq719j3f8.woff2 71368
dm_sans_latin-s.p.2_mbdogr7ni8i.woff2 36980
space_grotesk_latin-s.p.2cyn07wtgehh0.woff2 22320
The two fonts I subset to latin and committed by hand are 59KB together. The one that still comes from a package, and which exists on this site to set a wordmark in the nav, a few domain labels and some monospaced prices, is 71KB on its own and carries every glyph it shipped with. The repo already contains the technique for fixing that, in the two files next to it. It just has not been pointed at the third font yet.
Top comments (0)