135 KB for three typefaces: how the fonts on this site are cut
Two independent axes of waste in a variable font, coverage scoped to a typeface's role instead of its family, and why the rouble sign is not in the subset where you would look for it.
Three typefaces, three files, 135.5 KB in total. What follows is what was done to them, which of those decisions are non-obvious, and where the trap in this task is — the one that puts an empty box on your pricing page instead of a currency symbol.
Why self-host instead of linking Google Fonts
The first reason is not performance. fonts.googleapis.com receives the visitor's IP on every page view: a third-party transfer with no basis and no mention in the privacy notice. It is the same defect that got Google Analytics removed from this project, with a smaller blast radius. Self-hosting removes the third-party request outright rather than hiding it behind a consent banner.
The second reason is real but secondary: one same-origin woff2 per family, immutable and preloaded, beats a cross-origin CSS round trip plus an unpredictable number of unicode-range fragments. Self-hosting has a price too — upstream version bumps are now a manual job. For three files that is an acceptable price.
Two independent axes of waste
A variable font carries excess weight along two axes at once, and most people cut only one of them.
The first is the variable wght axis itself. The file ships the full designed range while the design system uses three or four values from it. fontTools.varLib.instancer narrows the range:
python -m fontTools.varLib.instancer Unbounded.ttf wght=400:800 -o sliced.ttf --update-name-table
--update-name-table is not cosmetic. Without it the file keeps advertising axes it no longer contains, and the browser is then entitled to promise a weight it cannot render.
The second axis is the character set, handled by fontTools.subset with the ranges spelled out. You need both operations: narrowing wght without subsetting leaves the whole of Unicode in place, and subsetting without narrowing wght leaves every variation delta behind.
The trap: the rouble sign is not where you look for it
₽ is U+20BD, and it lives in latin-ext, not in latin. The Google Fonts definition of latin reaches U+20AC — the euro sign — and stops there as far as currency goes.
The practical consequence is unpleasant. You subset to latin + cyrillic, an entirely reasonable choice for a Russian-and-English site, open the page and find a tofu box where the price should be. And you will not catch it quickly, because any test written in English passes: the English version quotes prices in dollars.
So the build script carries an explicit range of its own:
const CURRENCY = "U+20BD,U+20AC,U+0024,U+2116";
Four codepoints, three of which are already covered by the larger ranges. The duplication is deliberate: a few bytes against the scenario where somebody narrows a main range and silently drops a currency glyph. Listing them explicitly turns the set into an assertion — these four must be in the file whatever happens to the rest of the list.
Coverage scoped to role, not to family
The central decision in the script is that the character set is chosen for a typeface's job on the site, not for the typeface.
Unbounded is display-only: the hero, H1 to H3, the wordmark. It never sets prose. That makes latin-ext and cyrillic-ext dead weight in it — Russian and English headings need latin and cyrillic and nothing else. Dropping the extended ranges is what brought it inside its budget.
Onest sets every body string in both locales, legal documents included, so it keeps the extended ranges: a Ukrainian or Kazakh name in a lead, ₴, ў, ґ. That is not hypothetical. A name field takes whatever the person typed into it, and a tofu box in someone's own name is a poor way to greet them.
JetBrains Mono sets the terminal layer: ASCII, box drawing (U+2500–257F), and enough Cyrillic for the Russian terminal strings.
The difference between subsetting a font and subsetting it for its role is roughly a factor of two, and it costs nothing to obtain: you already know what each typeface is for, or it would not be in the project.
The numbers
Read off disk — exactly what is committed:
public/fonts/Unbounded-Variable.woff2 60,436 B 59.0 KB budget 90
public/fonts/Onest-Variable.woff2 43,204 B 42.2 KB budget 80
public/fonts/JetBrainsMono-Variable.woff2 35,116 B 34.3 KB budget 60
138,756 B 135.5 KB
The budget is enforced by the script itself: over budget is a non-zero exit code. A budget that exists only in code review is not a budget.
What is deliberately missing is a before-and-after figure. The natural comparison is the upstream TTF against the subset woff2, and that conflates two entirely different effects: brotli compression and glyph removal. Such a comparison flatters your subsetting work by a factor of two or three. If you want to know what subsetting bought you, compress the unsubsetted file to woff2 first and compare against that. I have not run that measurement, so I am not quoting a number for it.
Flags worth knowing before you need them
--layout-features is an allowlist, not an exclusion list. Enumerate carelessly and you lose kerning. The script keeps kern, liga, clig, calt, rlig, locl, ccmp, mark, mkmk and drops swashes, alternates and historical forms. locl earns its place specifically because of Cyrillic: Bulgarian and Serbian localised letterforms are not decoration, they are the correct shapes for those languages.
--no-hinting is right for woff2 on modern renderers and wrong if you still support old Windows GDI. --notdef-outline keeps a visible rectangle where a glyph is missing instead of a zero-width nothing: you want to see the failure, or it degrades into "something looks odd about the spacing here". --recalc-bounds is needed because slicing an axis invalidates the bounding box in the head table.
The Elder Futhark none of the three ships
None of the families contains runes (U+16A0–16F8). That was not discovered in production: the runic layer is part of the identity, so it was authored as SVG paths in components/brand/Rune.tsx from the start.
It turned out to be the better answer rather than a workaround — exact control over stroke geometry, no font-loading dependency for a glyph that is part of the logo, and no fourth file on the critical path.
Loading
Display and body are preloaded; the monospace is not, because the terminal layer only appears below the fold and there is no reason to pay for it on the critical path. All three use display: swap — a flash of system text is strictly better than an invisible heading. Fallback stacks are spelled out, and /fonts/* is served immutable.
One last detail: two namespaces, kept apart. app/fonts.ts declares variables named after the typeface (--font-unbounded), and the @theme block in globals.css maps those onto roles (--font-display: var(--font-unbounded)). A component asks for font-display and never learns a typeface name. Beyond the obvious payoff — replacing Unbounded is a one-line change — this is what avoids the self-referential --font-display: var(--font-display) that everyone who names the variable by role at both levels runs into.
The point
File size on its own is not an argument; 135 KB can be arrived at by accident. The argument is that every byte in those three files is there for a stated reason, and the script fails the build when that stops being true.