Cut your web fonts down to the characters you use
A font file carries hundreds of glyphs, and this site uses 119 characters. Subsetting at build time cut the preloaded fonts by a quarter.
How many of the letters in your web font does your site actually show? A font file is built to set any text in its languages, so it carries hundreds of glyphs (the drawn shapes of letters, digits and symbols). This site, with all its notes, uses 119 different characters, and only 24 of them are not on a US keyboard.
The two fonts this site loads on every page weighed 61.3 KB together. That's often more than the page itself.
To be honest, saving a few kilobytes on a site that is already fast sounds a bit ridiculous. I did it anyway, just to see how far I could go. This note shows how the build now cuts the fonts down to the characters the site ships, why the result looked the same on screen, and the one cache trap you have to plan for.
Font services do a coarse version of this already. The files this site self-hosts come split by language: one "latin" file for English and western European text, and a "latin-ext" file for the rest. Each @font-face declares a unicode-range, and the browser only downloads the latin-ext file when a page uses one of its characters. A subset goes one step further and keeps only the characters you actually ship.
Let the build collect the characters
You could write the list of characters by hand. But then every new note with a new character, say an em dash or a German umlaut, means updating the list, and forgetting it gives you a letter in the wrong font.
A static site doesn't need a list. At build time everything a visitor can see already exists as text: the rendered pages, the CSS, the JavaScript of the interactive parts and the data files. So the build renders every page once, collects every character from all of it, and adds the printable ASCII characters (the basic English keyboard set) so a code sample or a link never misses one.
The subsetting itself is one library call. This site uses subset-font, which runs HarfBuzz, the text engine used in Chrome and Firefox, as WebAssembly. The host can run it without installing native tools:
import subsetFont from "subset-font";
const full = await Bun.file("space-grotesk-latin.woff2").bytes();
const small = await subsetFont(Buffer.from(full), "Hello, world", {
targetFormat: "woff2",
});
small is a working font file that only knows the characters of "Hello, world". In the build, the second argument is the collected text of the whole site.
What it saved
These are the sizes of the files a visitor loads on every page:
| Font file | Before | After |
|---|---|---|
| Space Grotesk, latin | 21.8 KB | 14.1 KB |
| JetBrains Mono, latin | 39.5 KB | 31.6 KB |
| Both together | 61.3 KB | 45.7 KB |
The first visit to a note page went from 75.2 to 59.8 KB, measured in headless Chromium with a throttled phone profile. The time until the main text appears did not change: 1516 ms before and 1528 ms after, the median of 5 runs each, which is within the noise. That's because this site already shows text right away in a local fallback font that is scaled to the same size, and swaps in the web font when it arrives. So the subset saves data on every first visit, but it doesn't make the first paint faster here.
The build takes under a second longer, over a few builds.
Same pixels, or it doesn't ship
A smaller font is only worth it if nobody can see a difference. So I took full-page screenshots of six pages at desktop and phone width, once from the old build and once from the new one, and compared them pixel by pixel. In the first run all twelve were identical. A second run found 5 different pixels on one page. Comparing the old build with itself shows that kind of flicker too, on a page with an animation.
That check also stopped an extra step. The fonts are variable fonts: one file holds every weight from thin to bold on a weight axis. The CSS only uses weights between 400 and 700, so I also cut the axis down to that range. That saved another 8 KB on JetBrains Mono, but now 11 of the 12 screenshots had different pixels. I didn't dig into why. The rule was same pixels, so the axis stays whole.
I also kept the font's layout rules. JetBrains Mono draws some character pairs, like an arrow made of a hyphen and a greater-than sign, as one combined glyph called a ligature. Dropping those rules would have brought the file down to about 17 KB, but it would also drop the ligatures from every code sample. I only measured its size, so I kept the rules.
The cache trap
Before this change, a font file never changed, so its URL could stay the same forever and browsers could cache it for a week. A subset changes whenever the content does. Publish a note with a new character, and the new subset has a glyph the old one didn't.
A returning visitor whose browser still has the old file would see that one character in the fallback font. The fix is the same one sites use for CSS and JavaScript: put a hash of the file in its URL. The build now writes something like ?v=a68521f707 after each font URL, in the CSS and in the preload links. A new subset gets a new URL, so the browser fetches it, and an unchanged font keeps its URL and stays cached. And because the URL now changes with the file, the font can be cached for a year instead of a week.
Keep a safety net
Not every character comes from the build. Anything a visitor can type, a search box, a comment form, or the terminal on the home page here, can bring characters the build never saw. So only the two latin files are cut down. The latin-ext files stay whole and still catch characters the build never saw.
One surprise from the comparison: the arrows (→, ←), the approximately sign (≈) and the line characters the terminal draws trees with were never in the latin files. They were already coming from the fallback font before any of this. A subset can only keep what the full file had. And a test now checks after every build that every shipped character the full font had is still in the subset.
Collect the characters from the build, check the pixels, and version the URL. Then the font only carries what your site shows.