Shrink variable webfonts by removing the axes you don’t use
Fonts were more than 90% of the bytes on a post here. Subsetting to Latin had already been done, since that is how Google Fonts serves them. The weight was in the variation data: an optical-size axis the page pins at one value and a weight range it uses a sixth of. Instancing both away cut the fonts by 73%.
A post on this site loads four font files: Schibsted Grotesk for the interface, JetBrains Mono for code, and Newsreader in roman and italic for the body text. With the files exactly as Google Fonts serves them, that is 366 KB of fonts beside 34 KB of compressed HTML, CSS and JavaScript. The fonts are 92% of the page.
Those files are already subset. Google splits each family by script and the browser only fetches the latin file for English text. The usual advice, “subset your fonts”, has nothing left to remove.
Where the bytes are
ttx -l from fonttools lists a font’s tables with their sizes before compression:
pip install fonttools brotli ttx -l Newsreader.woff2
tag checksum length offset
---- ---------- -------- --------
GDEF 0x51C5DEDC 25359 0
GPOS 0x43776B31 59768 25359
glyf 0xB7C86A2F 16473 89104
gvar 0xF50264B2 117256 105577
hmtx 0xD29336EB 1048 222923
...The outlines of all 262 glyphs, glyf, are 16 KB. gvar is 117 KB, more than half of the uncompressed font. That table holds the deltas that move every point of every glyph along each variation axis, and Newsreader has two:
| Axis | Range in the file | What this site uses |
|---|---|---|
wght |
200 to 800 | 400 and 500 |
opsz |
6 to 72 | body text at 19px, the lede at 21px |
In the compressed file, deleting gvar outright takes the 132 KB WOFF2 down to 58 KB. That is not a usable font, but it shows how much of the download is range the page never asks for.
Instance the font
Instancing rewrites a variable font with an axis pinned to one value or limited to a smaller range. fonttools varLib.instancer does it from the command line:
fonttools varLib.instancer Newsreader.woff2 opsz=19 wght=400:500 -o newsreader-instanced.woff2
opsz=19 pins the optical size and removes the axis. wght=400:500 keeps weight variable, but only between the two values the stylesheet uses. Each step on its own, and both together, for the Latin roman file:
| Newsreader, latin | WOFF2 bytes |
|---|---|
| As served by Google Fonts | 131,848 |
wght=400:500 only |
88,076 |
opsz=19 only |
57,332 |
| Both | 37,152 |
| Both, then subset again | 33,356 |
| One static weight, 400 | 22,272 |
A range of 400 to 500 costs 37 KB. Two static files, one per weight, would be 22 to 23 KB each, so the narrow variable font is smaller than the pair and still gives every weight in between.
The italic is only ever drawn at 400, so it becomes a static instance:
fonttools varLib.instancer Newsreader-Italic.woff2 opsz=19 wght=400 -o newsreader-italic-instanced.woff2
147,060 bytes down to 21,400.
Subset again after instancing
The instanced file still carries glyphs and OpenType features outside the range it will be served for. pyftsubset trims both. The --unicodes list is the same unicode-range Google publishes for its latin file, so the @font-face rule and the font agree on what the file covers:
LATIN="U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD" pyftsubset newsreader-instanced.woff2 \ --unicodes="$LATIN" \ --layout-features=kern,liga,clig,calt,locl,mark,mkmk,ccmp,rlig \ --flavor=woff2 \ --output-file=newsreader-latin.woff2
--layout-features is a keep list. Anything not named is removed, which here drops Newsreader’s tabular and proportional figure features. If your design uses font-variant-numeric or small caps, add those tags.
Drop the coding ligatures from the mono font
JetBrains Mono turns != into ≠ and -> into an arrow through its calt feature. On a blog that is a problem: a reader copying a line out of a tutorial should see the characters that are in it. Leaving calt, liga and clig off the keep list removes the feature, and the subsetter then removes the 139 ligature glyphs nothing else refers to:
| JetBrains Mono, latin | WOFF2 bytes | Glyphs |
|---|---|---|
As served by Google Fonts, wght 100 to 800 |
40,480 | 394 |
wght=400:500 |
29,516 | 394 |
| Subset, ligatures kept | 27,856 | 388 |
| Subset, ligatures dropped | 13,528 | 249 |
More than half of that font was ligatures.
What changes on screen
At the pinned size and inside the kept weight range, nothing. Instancing the Google file at opsz=19, wght=400 and the new file at wght=400 gives outlines that differ by at most 1 unit on a 2,000 unit em, which is rounding. At 500 the largest difference is 1.6 units. Schibsted Grotesk and JetBrains Mono come out the same way.
Pinning opsz is a real change for any text that is not 19px. Browsers apply optical sizing automatically (font-optical-sizing: auto), so with the full file the 21px lede was drawn with the 21 design. It is now drawn with the 19 design, which sets a line of text 0.4% narrower. That is the price of removing the axis, and it is small because the two sizes are close. A site that uses one family for both 14px captions and 72px headlines would lose a lot by pinning: the 6 design is 26% wider than the 19 one. There, limit the axis to a range (opsz=14:72) or serve two instances.
Make the stylesheet match the file
The @font-face rule has to describe what is in the file now:
@font-face { font-family: 'Newsreader'; font-style: normal; font-weight: 400 500; font-display: swap; src: url("assets/fonts/newsreader-latin.woff2") format("woff2"); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: 'Newsreader'; font-style: italic; font-weight: 400; font-display: swap; src: url("assets/fonts/newsreader-italic-latin.woff2") format("woff2"); unicode-range: /* same list */; }
font-weight: 400 500 is the range the file can draw. Leave the old 200 800 there and a font-weight: 700 heading is given this font at its heaviest, 500, with no synthetic bold, and looks wrong for no visible reason.
The result
| File | Google Fonts | Instanced | Saved |
|---|---|---|---|
| Newsreader, latin | 131,848 | 33,356 | 75% |
| Newsreader italic, latin | 147,060 | 21,400 | 85% |
| Schibsted Grotesk, latin | 46,864 | 32,720 | 30% |
| JetBrains Mono, latin | 40,480 | 13,528 | 67% |
| All eight files, with latin-ext | 584,340 | 159,912 | 73% |
For a post that uses all four, the fonts went from 366 KB to 101 KB and the whole page from 400 KB to 135 KB. Schibsted Grotesk saves the least because its file has one axis and no optical sizes to remove.
Two things to check after deploying
A preload must use the exact URL the stylesheet requests. The fonts the first screen needs are preloaded:
<link rel="preload" href="/wp-content/themes/dipinkrishna/assets/fonts/newsreader-latin.woff2" as="font" type="font/woff2" crossorigin>The browser matches a preload to a later request by URL. A cache-busting ?ver=1.7.0 on one and not the other makes them two resources, and the font downloads twice. crossorigin is required even for a same-origin font, because font requests are always made in CORS mode. In DevTools, each font should appear once in the Network panel.
Returning visitors keep the old files. Fonts are usually served with a one-year max-age. If the smaller file keeps the name of the one it replaces, anyone who has the old file cached goes on using it until it expires. New visitors get the small one immediately. Rename the files if you want everyone on the new ones at once.