Font subsetting, measured
Most advice about font subsetting stops at "it makes fonts smaller." That statement is true and useless. It does not tell you how much smaller. It does not tell you which step made the difference, or why your subset came out two times too large.
So I subsetted a shelf of real fonts and wrote down the results. Every number below is measured, not estimated. You can repeat all of them with the commands in this article.
Where the bytes go
Two different operations make a font smaller. Keep them separate, because one is free and the other needs decisions from you.
Compression is free. A raw TTF file that you convert to WOFF2 keeps all of its data. The conversion changes nothing in the font and needs no decisions from you:
| Font | Source TTF | WOFF2 | Saved |
|---|---|---|---|
| Roboto 400 | 477.1 KB | 217.2 KB | 54% |
| Epilogue 100–900 | 198.2 KB | 78.7 KB | 60% |
| DM Serif Display 400 | 74.8 KB | 30.9 KB | 59% |
| Noto Sans JP 100–900 | 9365.1 KB | 4248.1 KB | 55% |
The result is 55% to 60% for no work. If you still send TTF or OTF files to browsers, this one command gives you the largest possible improvement.
Subsetting needs decisions. Subsetting removes glyphs. The quantity that you save depends on how much of the font you were never going to use:
| Font | Full WOFF2 | Latin subset | Saved |
|---|---|---|---|
| Roboto 400 | 217.2 KB | 59.4 KB | 73% |
| Epilogue 100–900 | 78.7 KB | 28.6 KB | 64% |
| DM Serif Display 400 | 30.9 KB | 15.6 KB | 49% |
| Noto Sans JP 100–900 | 4248.1 KB | 44.3 KB | 99% |
Look at how different those percentages are. DM Serif Display is a display face with a small character set, so a Latin subset saves only 49%. Noto Sans JP contains CJK characters, so a Latin subset removes 99% of the file. The size of your saving is a property of the font, not of the technique. Measure your font before you assume.
The flag that makes your file two times too large
This part is difficult to find in other articles. It is the reason that people get subsets much larger than they expect.
The same font and the same characters, which are the printable ASCII set, give four different results:
| Layout features kept | Size | Glyphs |
|---|---|---|
--layout-features='*' | 59.4 KB | 226 |
'*', hinting removed | 54.8 KB | 226 |
| Default features only | 33.7 KB | 100 |
| No layout features | 31.4 KB | 96 |
If you keep all the OpenType features, the file becomes 76% larger than the file with the default features. That is 59.4 KB against 33.7 KB, for a subset with the same readable characters.
The 126 extra glyphs are real glyphs. But they are alternate forms, small capitals,
historical forms, and stylistic sets. Your CSS can reach them only through features that
you never turn on. The subsetter cannot know that you will not use them. When you give it
'*', it keeps every glyph that any feature can substitute. The subsetter obeys you.
Many tutorials tell you to use --layout-features='*' to stay safe. That advice is the
reason that your subset is two times too large.
Keep the default features. They contain kerning, standard ligatures, contextual alternates, and the mark positions that scripts such as Arabic and Devanagari need:
pyftsubset font.ttf \
--unicodes=U+0000-00FF \
--layout-features=kern,liga,clig,calt,ccmp,locl,mark,mkmk,rlig \
--flavor=woff2 \
--output-file=font.woff2
Add one more feature only when you use it. If you turn on font-variant-numeric: tabular-nums
in your CSS, you must add tnum to this command. If you do not add it, the browser asks
for glyphs that the file no longer contains. The browser then shows the default figures.
Subset by the characters that you show, not by the language name
If you divide Roboto by script, you get these results:
| Range | Size | Characters |
|---|---|---|
| Latin basic (U+0000–00FF) | 76.0 KB | 194 |
| Latin Extended (U+0100–024F) | 42.6 KB | 146 |
| Cyrillic (U+0400–04FF) | 82.8 KB | 255 |
| Greek (U+0370–03FF) | 31.6 KB | 75 |
This division is correct for body text when you cannot predict the content. But a heading font usually shows only a small number of characters. Subset it to those characters:
pyftsubset display.ttf --text="Subset your fonts" \
--flavor=woff2 --output-file=display.woff2
Roboto with one headline is 13.8 KB. Noto Sans JP is 9 MB, but with one headline it is 5.9 KB. That is a decrease of 99.9%. A display face that shows one line loses nothing.
The rule is this: the more you can predict the text, the more you can remove. You can predict logos, headlines, and navigation. You cannot predict text from users.
CAUTIONDo not subset a font by text if a CMS or a user can send other characters to it. The browser shows nothing for a character that the file does not contain. You will not see the problem, because you do not type the character that disappears.
Divide the font with unicode-range
For body text with characters that you cannot predict, supply more than one subset. The browser then downloads only the ranges that the page uses:
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-latin.woff2') format('woff2');
unicode-range: U+0000-00FF;
font-display: swap;
}
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto-cyrillic.woff2') format('woff2');
unicode-range: U+0400-04FF;
font-display: swap;
}
The family name is the same and the ranges are different. An English page downloads 76 KB and ignores the Cyrillic file. A Russian page downloads the other file.
NOTEGoogle Fonts does this division for you when you link to a stylesheet. Learn this before you decide to serve the files yourself.
Variable fonts change the arithmetic
A variable font is one file with a range of weights. Compare four static weights against one variable file:
- Four static Roboto weights with a Latin subset: about 238 KB.
- One variable Roboto 100–900 with a Latin subset: 59.4 KB.
That is one request in place of four, and one quarter of the bytes. But you get this result only if you use three weights or more. With one weight or two weights, static files are usually smaller. A variable font contains data for every weight between the two limits, and you do not use those weights.
Count the weights in your design before you decide. Two weights do not need a variable font.
The sequence that gives the most result for the least work
- Convert the font to WOFF2. This step removes 55% to 60% and needs no decisions. Supply only WOFF2. All current browsers support it, and a WOFF file adds a file that nobody downloads.
- Subset the font to the ranges that you show. This step gives the largest decrease. Measure the result, because it changes with each font.
- Keep the default layout features. Do not keep all of them. This step removes about 40% more.
- Set
font-display: swap. This step costs nothing. The browser shows the text immediately in a fallback font. - Preload one font or two fonts that the top of the page needs.
<link rel="preload" href="/fonts/heading.woff2" as="font" type="font/woff2" crossorigin>
CAUTIONDo not remove the crossorigin attribute, even for a file on your own server.
Browsers request fonts in anonymous CORS mode. Without the attribute, the preload does not
match the later request, and the browser downloads the file two times.
Examine your work
After you subset a font, make sure that the glyphs that you need are still in the file:
python -c "
from fontTools.ttLib import TTFont
f = TTFont('font.woff2')
print(len(f.getBestCmap()), 'characters')
print(len(f.getGlyphOrder()), 'glyphs')"
A number that is much lower than you expect shows that your --text value is incomplete.
Curly quotation marks, en dashes, and non-breaking spaces are the usual losses. They are
not ASCII characters, and they occur in text from word processors.
Then load the page with a slow network setting and read it. Byte counts do not show a missing glyph. Your eyes show it.
I made every measurement with fontTools 4.62 and the fonts in the tables. Run the commands on your own files. Your fonts will give different numbers, and that is the purpose of this article.