Two commands, same site, two different answers:
curl -s https://pub-trivia.app/faq | grep -o 'as="font"' | wc -l
# 2
curl -s https://pub-trivia.app/pricing | grep -o 'as="font"' | wc -l
# 0
The second one looks like a missing optimisation. It is not. Ask for the headers instead:
curl -sI https://pub-trivia.app/pricing | grep -i '^link:'
link: </_next/static/media/797e433ab948586e-s.p.<hash>.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2",
</_next/static/media/caa3a2e1cccd8315-s.p.<hash>.woff2>; rel=preload; as="font"; crossorigin=""; type="font/woff2"
The same two preloads, arriving as an HTTP Link header rather than as markup. And the same command against /faq returns nothing at all, because that page's preloads were in the HTML you already fetched.
The split follows one thing: whether the page was prerendered at build time or rendered per request.
curl -sI https://pub-trivia.app/faq | grep -i 'x-nextjs-prerender\|x-vercel-cache'
# x-nextjs-prerender: 1
# x-vercel-cache: HIT
curl -sI https://pub-trivia.app/pricing | grep -i 'x-nextjs-prerender\|x-vercel-cache'
# x-vercel-cache: MISS
A prerendered page is a file. React emitted the preload hints while building it, they were written into the <head>, and the CDN hands you the whole thing. A page rendered for this request is streamed, and React can still tell the client what to fetch early by putting the hints in the response headers, which arrive before the body does. Of the 72 URLs in our sitemap, 70 are of the first kind and 2 are of the second. The two are the homepage and /pricing, both of which look at something about the request. The sign-in pages are dynamic as well, and they are deliberately not in the sitemap.
There is a config flag that exists only for the header route, and we had already set it for reasons that had nothing to do with this:
// Increase header size limit for better font preloading
// Allows React to emit more preload hints during rendering
reactMaxHeadersLength: 8000,
The default is 6000 characters and React silently stops emitting hints past it. One of the hints it stops emitting is usually a font, because fonts come last in the list.
What is actually being preloaded
Two files, and no requests to Google at all:
curl -s https://pub-trivia.app/ | grep -c 'fonts.gstatic.com\|fonts.googleapis.com'
# 0
The declaration is eight lines in the root layout:
import { Geist, Geist_Mono } from "next/font/google";
const geist = Geist({
subsets: ["latin"],
display: 'swap',
variable: '--font-geist',
});
const geistMono = Geist_Mono({
subsets: ["latin"],
display: 'swap',
variable: '--font-geist-mono',
});
next/font/google downloads the font files at build time and serves them from our own domain. Nothing about the font is fetched from a Google origin at runtime, which is the part people get wrong when they reach for a <link> to fonts.googleapis.com out of habit: that version makes two cross-origin requests on the critical path, one for a stylesheet and one for the font the stylesheet names, before any text can paint in the right typeface.
The generated CSS has 13 @font-face rules in it. You can count them yourself by pulling the stylesheet the page links and grepping, and the breakdown is the interesting bit:
- Geist, 5 rules, one per unicode-range subset, of which exactly one is preloaded
- Geist Mono, 6 rules, same arrangement, one preloaded
- Geist Fallback, 1 rule
- Geist Mono Fallback, 1 rule
We asked for subsets: ["latin"] and got rules for cyrillic, cyrillic-ext, vietnamese and latin-ext as well. That is correct behaviour rather than a leak: the subset you declare is the one that gets a preload, and the rest sit in the stylesheet with a unicode-range that means the browser downloads them only if the page actually contains a character from that range. Declaring the subset chooses what to fetch eagerly, not what exists.
The two fallback rules are the ones worth stealing an idea from:
@font-face{font-family:Geist Fallback;src:local(Arial);ascent-override:95.94%;descent-override:28.16%;line-gap-override:0.0%;size-adjust:104.76%}
@font-face{font-family:Geist Mono Fallback;src:local(Arial);ascent-override:74.67%;descent-override:21.92%;line-gap-override:0.0%;size-adjust:134.59%}
Those download nothing. They are Arial, already on the machine, with its metrics bent to match the font that is still loading: scaled to 104.76 percent for Geist and 134.59 percent for Geist Mono, with the ascent and descent overridden. display: swap means the fallback paints first and the real font replaces it, and the classic cost of that is the layout shifting when the swap happens. Give the fallback the real font's metrics and the swap moves nothing. The 134.59 percent figure is the fun one: it is a proportional font being stretched to stand in for a monospace one, which works because what matters is the line box, not the letterforms.
The thing we found while writing this
The two preloaded files are 29,288 bytes for Geist latin and 23,108 bytes for Geist Mono latin. Both are preloaded, at high priority, on every page.
Then we went looking for where the mono face is used. Five places in the entire app: the HTTP status line on our rate-limit page, the error digest on two error boundaries, a timestamp inside one dashboard modal, and the fake URL bar in the product mock on the landing page.
Which means on a page like /faq, or any of the fourteen quiz question pages, 22.6 KB of monospace font is fetched at high priority to render nothing. Not deferred, not conditional: preloaded, because the variable is applied to <body> in the root layout, and that is enough for the font loader to decide it is used.
The fix is one argument, preload: false on the mono loader, which leaves the @font-face rule in place so the few elements that want it still get it, just without the eager fetch. We have not shipped it yet, and this post is the note, because a 22.6 KB high-priority request that renders nothing on almost every page is exactly the kind of thing that is invisible in a Lighthouse score and obvious in a waterfall.
That is the general lesson, and it is why this post is mostly curl commands. The font setup was three lines of config and looked finished. Everything interesting about it, the two transports for the same hint, the subsets we did not ask for, the stretched Arial, the eager fetch for a face nothing uses, only showed up from outside, by asking the live site what it actually sends.
Check it on our pages
-
The FAQ is prerendered, so its preloads are in the HTML. View source and search for
as="font". -
Pricing is rendered per request, so its preloads are in the
Linkheader. Open the Network tab, click the document, and read the response headers. - Our rate-limit page is one of the five places the monospace font earns its keep. The "HTTP 429" line is the whole of its contribution to that page.
Everything above is checkable with one terminal and no account. The app itself has a free tier that needs no card, if you would rather look at the half of the site that is behind a login.
Top comments (0)