DEV Community

Daniel Pertu
Daniel Pertu

Posted on

subsets in next/font is a preload list, not a download filter: 3 families, 20 font-face rules, 17 files

Nakodo uses three typefaces: Besley for display, Archivo for everything else, and IBM Plex Mono for addresses, handles and catalogue numbers. They are declared once, in the root layout, with three calls:

import { Archivo, Besley, IBM_Plex_Mono } from "next/font/google";

// Fonts are downloaded at build time and served from our own domain, so the
// browser never contacts Google.
const display = Besley({
  subsets: ["latin", "latin-ext"],
  style: ["normal", "italic"],
  variable: "--font-besley",
});
const ui = Archivo({
  subsets: ["latin", "latin-ext"],
  axes: ["wdth"], // condensed labels
  variable: "--font-archivo",
});
const code = IBM_Plex_Mono({
  subsets: ["latin", "latin-ext"],
  weight: ["400", "500"],
  variable: "--font-plex-mono",
});
Enter fullscreen mode Exit fullscreen mode

The first claim in that comment is true and checkable:

$ curl -s https://nakodo.app/ | grep -c 'gstatic\|fonts.googleapis'
0
Enter fullscreen mode Exit fullscreen mode

The second thing I believed, that subsets: ["latin", "latin-ext"] means two files per family, is not what the option does.

What the deployment actually serves

One CSS chunk carries every @font-face. On the live site:

$ curl -s https://nakodo.app/ | grep -o 'href="/_next/static/[^"]*\.css"'
href="/_next/static/immutable/chunks/3lx8-zlp93hp8.css"
href="/_next/static/immutable/chunks/10cobkkku2ww9.css"

$ curl -s https://nakodo.app/_next/static/immutable/chunks/3lx8-zlp93hp8.css \
    | grep -o '@font-face' | wc -l
20
Enter fullscreen mode Exit fullscreen mode

Twenty rules, 17 of them pointing at a woff2 file. Pulling each one's family, style, weight, unicode range and transferred size out of that CSS gives the whole inventory:

Family Style Weight Subset Preloaded Bytes
Besley italic 400 900 latin-ext yes 19,480
Besley italic 400 900 latin yes 39,476
Besley normal 400 900 latin-ext yes 18,440
Besley normal 400 900 latin yes 36,532
Archivo normal 100 900 vietnamese no 34,364
Archivo normal 100 900 latin-ext yes 85,856
Archivo normal 100 900 latin yes 90,096
IBM Plex Mono normal 400 cyrillic-ext no 4,348
IBM Plex Mono normal 400 cyrillic no 5,476
IBM Plex Mono normal 400 vietnamese no 4,000
IBM Plex Mono normal 400 latin-ext yes 8,860
IBM Plex Mono normal 400 latin yes 10,052
IBM Plex Mono normal 500 cyrillic-ext no 4,336
IBM Plex Mono normal 500 cyrillic no 5,500
IBM Plex Mono normal 500 vietnamese no 4,036
IBM Plex Mono normal 500 latin-ext yes 8,848
IBM Plex Mono normal 500 latin yes 10,060

389,760 bytes of font files on the CDN, of which the ten marked preloaded are 327,700. Seven files, 62,060 bytes, are Cyrillic and Vietnamese cuts of two families, for subsets that appear in none of the three calls.

The bundled Next docs say exactly what the option is, once you read it as written:

The font subsets defined by an array of string values with the names of each subset you would like to be preloaded. Fonts specified via subsets will have a link preload tag injected into the head when the preload option is true, which is the default.

So subsets is a preload list. Every subset the upstream font has gets an @font-face rule with its unicode-range, and the ones you named are the ones that get preloaded; the rest are declared and fetched only if a character in their range is painted. Nothing is wasted at request time, which is why the mistake is invisible: the Cyrillic files sit on the CDN and are never requested, because our pages have no Cyrillic in them. Had I kept believing subsets filtered the build, I would have gone looking for a bug the day a creator's name arrived with a Cyrillic character in it and rendered correctly.

The three rules without a file are the fallbacks:

@font-face{font-family:Besley Fallback;src:local(Times New Roman);ascent-override:100.86%;descent-override:34.29%;line-gap-override:0.0%;size-adjust:123.94%}
@font-face{font-family:Archivo Fallback;src:local(Arial);ascent-override:88.96%;descent-override:21.28%;line-gap-override:0.0%;size-adjust:98.7%}
@font-face{font-family:IBM Plex Mono Fallback;src:local(Arial);ascent-override:76.16%;descent-override:20.43%;line-gap-override:0.0%;size-adjust:134.59%}
Enter fullscreen mode Exit fullscreen mode

Those numbers are metric matching, computed at build time from the real font's own metrics. They are the reason text does not reflow when the web font lands: the local fallback is scaled and its ascent, descent and line gap are overridden so it occupies the same space. 123.94% against Times is also a fair description of Besley, a Clarendon revival with a much larger x-height.

The third rule is the one to notice. IBM Plex Mono's fallback is Arial, not a monospace face, and size-adjust matches a vertical metric rather than an advance width. So in the moment before the mono file arrives, our tables of addresses and handles are set in a proportional font at 134.59%: right height, wrong grid. That is Next's generated fallback rather than a choice of mine. The two options that touch it are fallback: ["ui-monospace", "monospace"], which supplies the font-family list, and adjustFontFallback: false, which the docs describe as switching off the automatic layout-shift fallback altogether. Both are trades: lose the metric match, or keep a proportional stand-in for a monospace column.

One width axis instead of a second family

Archivo is the biggest download here, 176 KB preloaded for two subsets, and that is deliberate:

axes: ["wdth"], // condensed labels
Enter fullscreen mode Exit fullscreen mode

By default next/font/google includes only the weight axis of a variable font, to keep the file small. Asking for wdth gives one file that covers font-stretch: 62% to 125%, which the CSS then uses for every column header, kicker and tag on the site:

/* Condensed caps for column headers, kickers and tags. */
@utility label-caps {
  font-family: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
  font-stretch: 75%;
  font-weight: 600;
  font-size: 11px;
  line-height: 16px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
Enter fullscreen mode Exit fullscreen mode

A condensed label family would have been a fourth font and a fourth set of files. The axis costs about 30 KB inside a file that was being downloaded anyway. That is the best trade in this whole setup.

The variables, and the one line that makes them work

The three calls only produce CSS variables. The mapping to Tailwind's own font tokens happens once:

@theme inline {
  --font-sans: var(--font-archivo), ui-sans-serif, system-ui, sans-serif;
  --font-heading: var(--font-besley), ui-serif, Georgia, serif;
  --font-display: var(--font-besley), ui-serif, Georgia, serif;
  --font-mono: var(--font-plex-mono), ui-monospace, monospace;
}
Enter fullscreen mode Exit fullscreen mode

The naming is deliberate and was nearly not. The obvious thing is to name the next/font variable --font-sans and be done, which produces --font-sans: var(--font-sans), a self-reference that only resolves because @theme inline substitutes at use site rather than storing the value. Naming the font variables after the fonts means the theme layer is the only place that decides which font is the sans.

Two base rules then do all the typographic work the components do not:

html {
  @apply font-sans;
  font-feature-settings: "ss01" 0;
}
table,
[data-numeric] {
  font-variant-numeric: tabular-nums;
}
Enter fullscreen mode Exit fullscreen mode

ss01 off globally is a taste decision about Archivo's alternates, and it is one line rather than a utility nobody would remember to add. [data-numeric] is the attribute version of the same thought: any element that holds figures gets tabular numerals, so a column of counts does not wobble as it updates.

59 KB of italic that nothing paints

Here is the finding I did not want. style: ["normal", "italic"] on Besley preloads four files, and the two italic ones are 58,956 bytes of the 327,700 preloaded on every page. Italic Besley exists for margin annotations and for the display line on the 404 page. Grep the live pages for it:

$ for u in / /pricing /how-it-works /about; do
    echo "$u $(curl -s https://nakodo.app$u | grep -o italic | wc -l)"
  done
/ 2
/pricing 2
/how-it-works 2
/about 2
Enter fullscreen mode Exit fullscreen mode

Two hits on every page, and both of them are the same string inside the React payload: the 404 page's font-display text-xl text-muted-foreground italic with its "Pagina absens". Not rendered, just serialised. No public page paints a single italic glyph, and all of them preload 59 KB of italic to be ready for it.

The fix is a second call rather than a second style:

const display = Besley({ subsets: ["latin", "latin-ext"], variable: "--font-besley" });
const displayItalic = Besley({
  subsets: ["latin", "latin-ext"],
  style: "italic",
  variable: "--font-besley-italic",
  preload: false,
});
Enter fullscreen mode Exit fullscreen mode

preload is per call, not per style, so the only way to stop preloading one style of one family is to declare it separately. That costs a second variable and a utility that switches to it, which is more code than the 59 KB is worth on a marketing page and less than it is worth on the app, where the annotation style actually appears. I have not decided yet, and I would rather publish the measurement than a conclusion I have not tested.

Everything here is measurable from outside: the CSS chunk is public, the font files are public, and the pages that use them are the landing page, pricing and the guides.

Top comments (0)