Skip to content

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.

Dipin Krishna

Written by Dipin Krishna

Senior full-stack engineer with 15 years across Django, Laravel, SwiftUI and the infrastructure underneath. Available for contract work.

Work with me →

Leave a note

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.