Optimizing Webfonts
2025-10-20Fonts are a critical part of web performance: Custom fonts can cause significant layout shifts, and their file size can be quite enormous. The fastest option is to bypass them completely by using web-safe fonts. However, a custom font can provide a lot of personality to a website, so many web designers consider them indispensable. So how can we make a font as small as possible to reduce its impact on web performance?
Typefaces
Let’s use Fixel, the font I’m using on this website, as an example. Fixel comes in three variants:
- A display variant for headings
- A text variant for continuous text
- A variable font
Both the text and display typeface come in 9 weights in both italic and regular. To get the nomenclature right, the Fixel Display typeface consists of 18 fonts (like FixelDisplay-Bold.otf). The variable font, on the other hand, covers the full range in a single font. This sounds like a great improvement, but it comes at a cost: The variable font has a much larger file size. Each of the fonts of the display and text typeface is around 120-140 kB. The variable font is 470 kB.
Compression
The first thing we can do is compress the font. We do this with the Web Open Font Format: WOFF21 is basically an OTF or TTF font file compressed with Brotli. Let’s compare the sizes:
| Font | Uncompressed Size | Compressed Size |
|---|---|---|
| Fixel Text Medium | 138 kB | 74 kB |
| Fixel Variable | 472 kB | 199 kB |
Compression makes a much bigger difference for the variable font. So now we can say that if you use three or more fonts of this typeface, you should use the variable font instead. This is only the beginning of our optimization, though.
Subsetting
On a conceptual level, a font file maps every character to a glyph. A font might contain a lot of characters that don’t actually appear on your website. Fixel, for example, contains both Latin and Cyrillic characters. This is an amazing feature if your page is available in both English and Ukrainian. But it is also an opportunity to reduce it to the characters you need on your specific website from the 600 symbols that Fixel offers in every font. That process is called subsetting.
We will use the Python-based fonttools for that. If you have Python and pip installed, you can install it like this:
pip install fonttools brotli uharfbuzz
Alternatively, I also prepared a Dockerfile.
We identify the characters we need by specifying code points in Unicode or ranges thereof. This website, for example, features texts in English and German. Let’s start with the 94 (printable) ASCII characters within the range 0000 to 007F. This covers all letters used in the English language, as well as some common punctuation like . and mathematical symbols like +. For the German language, we also require the three umlauts in upper and lower case, as well as the lower case Eszett2:
00C4,00D6,00DC,00DF,00E4,00F6,00FC
Without going all typography nerd, we need to talk about a few other characters:
- Quotation marks: If we don’t want to use ASCII quotation marks, we also need to add the respective language’s typographic quotation marks. We will thus add double quotation marks for “English” (
201Cand201D) and „German“ (201Eand201F). - Multiplication and division symbols: × and ÷ (
00D7and00F7) - The en dash and em dash3: – and — (
2013and2014) - Finally, apostrophe and ellipsis will make a few people really happy: ’ (
2019) and … (2026)
At this point, you might ask yourself what happens if you forget a character: Will it not render? With font-family: Fixel, system-ui, for each character not found in the first font the browser will fall back to the ones later in the list. This can lead to some quite quirky typography though:
Let’s use fonttools to subset our two fonts above to the characters we’ve identified. We pass it the characters we want to keep, tell it to keep all “layout features” (we’ll get back to that later), and to compress it to WOFF2:
compress-fonts fonttools subset\
--unicodes="0000-007F,00C4,00D6,00DC,00DF,00E4,00F6,00FC,201C,201D,201E,201C,00D7,00F7,2013,2014,2019,2026"\
--layout-features="*"\
--flavor=woff2\
FixelText-Medium.otf
This results in significant savings:
| Font | Uncompressed Size | Compressed Size | Subsetted |
|---|---|---|---|
| Fixel Text Medium | 138 kB | 74 kB | 29 kB |
| Fixel Variable | 472 kB | 199 kB | 74 kB |
What about user generated content?
The subset above will work well if your page features German and English text plus the most important typography-nerd glyphs. You will run into problems when your page accepts user-generated content. This is even true if your site is completely in English, and people can leave comments in English: if a user with the last name Krüger leaves a comment, the umlaut will not be rendered in your font of choice. An alternative is to use the full Latin-1 range: `0000-00FF`. As explained in the Wikipedia article, this will cover a wide range of languages.Typograhic Features
Modern fonts support a long list of typographic features. This goes way beyond the scope of this article. fonttools can strip down the features and comes with a default list of features it will keep that works quite well. It will, for example, keep ligatures, which browsers use by default if the font provides them (as is the case for Fixel). Previously, we told fonttools to keep all features with the --layout-features="*" option. When we drop that option, we get another drastic reduction in the file sizes:
| Font | Uncompressed Size | Compressed Size | Subsetted | Reduced Featureset |
|---|---|---|---|---|
| Fixel Text Medium | 138 kB | 74 kB | 29 kB | 11 kB |
| Fixel Variable | 472 kB | 199 kB | 74 kB | 31 kB |
Conclusion
In this article we saw how we can drastically reduce the file size of custom web fonts. We also saw the difference between a variable and non-variable font. In our case, the variable font is about three times the size of the non-variable font. We should therefore only use the variable font if we use more than two variants (combinations of font weight and italic/non-italic).
Thanks
Thanks to Marius and FND for their feedback ❤️
Footnotes
-
WOFF and WOFF2 basically have the same level of support, so at this point you can deliver WOFF2 without any fallbacks. ↩
-
There is also an uppercase Eszett, but we don’t need that unless we set German text in all-caps, as there is no German word that starts with an Eszett. ↩
-
You are allowed to use it even when you are not a large language model. ↩