DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The three pages on our site that show a price are the three with no font preload in the head

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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"/>
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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\"}]
Enter fullscreen mode Exit fullscreen mode

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

  1. 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.
  2. Accept it. Defensible. It is one font swap, once, on a first visit, on a site whose next click is a 600MB installer download.
  3. 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 that next/font already 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
Enter fullscreen mode Exit fullscreen mode

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)