A Young Goku (from Dragon Ball) wearing his Crane School uniform
Kunall Banerjee

Use font subsetting to slash your font file size significantly

15th January, 2024

I used font subsetting to slash the font file size of this website by 80-95%. Subsetting fonts is the practice of creating a “subset” of a font—a file that contains a custom, and often a limited, collection of glyphs. You usually create a file (or multiple files) that contain a limited range of characters or features from that font. For example, this website will only ever be written in English, so I could leave out my font stack’s Cyrillic characters to make a smaller font file.

Before you decide to optimize your website performance like this, consider using variable fonts, self-hosting them, or better yet, using local (system) fonts, and then finally look into subsetting your font.


Why would I do this and why should you care?

To put it simply: I care about a fair and inclusive experience for all my readers. I want you to be able to load my website and have a joyful experience: whether you’re mid-air on a flight using in-flight Wi-Fi, on your yacht using Starlink, in the Tube with a spotty mobile network, or even in space.

As for you, reader, because there is no such thing as “unlimited” data, even if you are fortunate enough to be living in a country to take advantage of something like that. Let’s say you’re a Bell customer in Canada:

Wireless Home Internet customers who subscribe to an unlimited usage package will not be impacted by the ITMP until they exhaust the maximum-speed usage in their plan. Once customers use up their maximum-speed usage allotment for the month, download and upload speeds will be reduced by 60% until the start of the next billing period.

— Bell.ca, ITMP for Wireless Home Internet

Let’s read what Bell has to say about your mobile data usage:

To what type of traffic does this ITMP apply?

This applies only to Mobile Internet plan traffic. If implemented, it will impact any and all usage by the affected Mobile Internet customers that exceeds the specified amount of data in their Mobile Internet plans until their next billing cycle.

Which customers are impacted?

All Bell customers who subscribe to a Mobile Internet plan may have their data speeds reduced once they have exceeded the monthly data allotted in their Mobile Internet plan.

This type of optimization might seem excessive, but consider this: over 60% of websites use custom or non-system fonts. If each website uses at least 2 fonts, then all of a sudden, the difference between downloading 840KB of font files vs only 80KB (90% total reduction in font file size) is significant, if not noticeable at first. These things may be hard to notice if you have good Internet speeds and use high-end (not in low-power mode) devices, but that’s not always the case.

If you’re a developer reading this, in short you get:

  • Lower CLS scores (lower the better)
  • Improve overall PageSpeed performance scores
  • If you’re self-hosting, you reduce your bundle size
  • Last but not least, the 3 factors above should improve SEO performance

If your font isn’t already in WOFF2 format

Sometimes the font you want to use doesn’t come in WOFF2 format. Let’s take an example: if you download Newsreader from Google Fonts, it only comes in .ttf format. The fonts (regular and italic styles) are almost 500KB each!

On macOS, you can use woff2_compress (which you can obtain via Homebrew):

woff2_compress Newsreader/Newsreader-Italic-VariableFont_opsz,wght.ttfProcessing Newsreader/Newsreader-Italic-VariableFont_opsz,wght.ttf => Newsreader/Newsreader-Italic-VariableFont_opsz,wght.woff2Compressed 485646 to 238959.

That’s already a 50% reduction in file size.

But we can reduce this number even further—and significantly.


Checking all glyphs, features, and variations of your font

The application FontGoggles is open loaded with the variable font Newsreader

If you’re on macOS, you can use FontGoggles to view these properties. As an example, it can be helpful to identify all the glyphs that come with your font, if you don’t have that information readily available. If you look at the screenshot above, you’ll notice that you also have all the unicode code unit next to each character. As you’ll see, we’ll use these code points to further reduce our font file size.

Newsreader comes with two separate font files, one for regular and one for italics. Each file has two variation axes: the weight (wght) and the optical size (opsz). For this website, I’ve restricted the weight to 400, which means I can further reduce the font file size using fonttools (via Homebrew):

fonttools varLib.instancer ./Newsreader-Italic-VariableFont_opsz,wght.woff2 wght=400 \-o ./Newsreader-Italic-VariableFont_opsz,wght_400.woff2

The command above doesn’t output the new file size, but doing ls -la we can see that by restricting the weight, we see another 50% reduction (53%, to be precise) in file size!

 ls -la.rw-r--r--@ 6.1k kimchi  4 Sep 00:47 .DS_Store.rw-rw-r--@ 493k kimchi 12 Aug 17:53 Newsreader-Italic-VariableFont_opsz,wght.ttf.rw-r--r--@ 239k kimchi  4 Sep 01:10 Newsreader-Italic-VariableFont_opsz,wght.woff2.rw-r--r--@ 111k kimchi  4 Sep 02:09 Newsreader-Italic-VariableFont_opsz,wght_400.woff2.rw-rw-r--@ 449k kimchi 12 Aug 17:53 Newsreader-VariableFont_opsz,wght.ttf.rw-rw-r--@ 4.5k kimchi  4 Sep 00:46 OFL.txt.rw-rw-r--@ 4.8k kimchi  4 Sep 00:46 README.txtdrwxr-xr-x@    - kimchi  4 Sep 00:47 static

Additionally, if you know (like in my case), you can further reduce the file size by fixing the optical size (although I do not recommend this):

fonttools varLib.instancer ./Newsreader-Italic-VariableFont_opsz,wght_400.woff2 opsz=12 -o ./Newsreader-Italic-VariableFont_opsz_12_wght_400.woff2

FYI: that brought the file size down from 111KB to 45KB. Now, we’re getting somewhere—but we’re still not done, though!


Setting the unicode range of a font to further reduce the font file size

However, before we do that, let’s disspell one common misconception about how to implement font subsetting. From MDN:

The unicode-range CSS descriptor sets the specific range of characters to be used from a font defined using the @font-face at-rule and made available for use on the current page. If the page doesn’t use any character in this range, the font is not downloaded; if it uses at least one, the whole font is downloaded.

There’s a common myth going around that using unicode-range: […] allows you to subset a font to contain only the characters you use. Unfortunately, that’s not true. Using unicode-range: […] simply forbids the browser from using characters from outside the range in the file defined by the @font-face rule. But those characters are still there and they’re still downloaded.

In order to truly reduce the font file size, we have to get our hands dirty (which I honestly don’t mind):

pyftsubset Newsreader-Italic-VariableFont_opsz_12_wght_400.woff2\--unicodes="U+0020-007F,U+00A0-00FF,U+0100-017F,U+2018,U+2019,U+201C,U+201D" \  --flavor="woff2" \--output-file="newsreader-subset.woff2"

The above unicode ranges enable Basic Latin (0020 — 007F), Latin-1 Supplement (00A0 — 00FF), and U+2018, U+2019 and U+201C, U+201D ‘smart quotes’ support. This is a silent output too, so doing ls -la once again to confirm things, we see:

 ls -la.rw-r--r--@ 6.1k kimchi  4 Sep 02:25 .DS_Store.rw-rw-r--@ 493k kimchi 12 Aug 17:53 Newsreader-Italic-VariableFont_opsz,wght.ttf.rw-r--r--@ 239k kimchi  4 Sep 01:10 Newsreader-Italic-VariableFont_opsz,wght.woff2.rw-r--r--@  45k kimchi  4 Sep 02:19 Newsreader-Italic-VariableFont_opsz_12_wght_400.woff2.rw-r--r--@ 111k kimchi  4 Sep 02:09 Newsreader-Italic-VariableFont_opsz,wght_400.woff2.rw-r--r--@  27k kimchi  4 Sep 02:49 newsreader-subset.woff2.rw-rw-r--@ 449k kimchi 12 Aug 17:53 Newsreader-VariableFont_opsz,wght.ttf.rw-rw-r--@ 4.5k kimchi  4 Sep 00:46 OFL.txt.rw-rw-r--@ 4.8k kimchi  4 Sep 00:46 README.txtdrwxr-xr-x@    - kimchi  4 Sep 00:47 static

… exactly a 40% reduction in font file size. We went from a 493KB font file to a mere 27KB. That’s an overall 95% reduction in font file size.

Now that we’ve subset the fonts manually, we can go ahead and set the exact same unicode ranges in CSS, like so:

@font-face {  font-family: "Inter";  font-display: swap;  font-style: normal;  font-weight: 100 900;  {/* prettier-ignore */}  unicode-range: U+0020-007F, U+00A0-00FF, U+0100-017F, U+2018, U+2019, U+201C, U+201D;   src: url("/fonts/font-sans-serif-regular-subset.woff2") format("woff2");}@font-face {  font-family: "Newsreader";  font-display: swap;  font-style: italic;  font-weight: 400;  {/* prettier-ignore */}  unicode-range: U+0020-007F, U+00A0-00FF, U+0100-017F, U+2018, U+2019, U+201C, U+201D;   src: url("/fonts/font-serif.woff2") format("woff2");}

Enabling the low-level features of OpenType fonts to reduce font size further

The sans-serif font used on this website is Inter v4. It currently supports 147 languages (or scripts), and also has support for a lot of features. I write primarily in English, so I decided to drop any script that is not Latin (English). Instead, I enabled some font features that I think make reading (semi)-long-form content (like this!) much more joyful. The same goes for Newsreader, which is already optimized for long-form reading.

pyftsubset public/fonts/InterVariable.woff2 \--unicodes="U+0020-007F,U+00A0-00FF,U+0100-017F,U+2018,U+2019,U+201C,U+201D" \--layout-features+="cv01,cv02,cv05,cv10,kern,ss03" \  --flavor="woff2" \--output-file="font-sans-serif-regular-subset.woff2"
Two Apple Finder window panes stacked on top of each other showcasing the reduction in font file size after subsetting

As you can tell from the screenshot, that’s another 80% reduction in font file size.

For example, if I load some text into FontGoggles that is not in the supported unicode range, here’s what I see:

An empty display seen in the application FontGoggles because I loaded text from a language not within the unicode range as stated in the above steps

Here’s some sample text: � � � Х ү н. In the first 3 instances, you should see your browser print the font’s built-in replacement character. The latter 3 should render just fine, thanks to the sane defaults applied by Tailwind:

font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif,  "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";

In this instance, your browser should switch to the next available font such that the missing glyphs are visible again.


Should you include this optimization in your performance budget?

If you want to build a fast web experience, then setting a performance budget is highly recommended. Especially more so if your website is content-heavy (not just in images, but text). You set a “budget” on your page and do not allow the page to exceed that. This may be a specific load time, but it is usually an easier conversation to have when you break the budget down into the number of requests or size of the page.

In the case of loading fonts, the more number of individual requests made by your browser is directly proportional to the number of fonts you have. Or rather, to be more precise, each font-case {...} declaration will result in a separate network request made by your browser to fetch the font and its styles and weights.

That being said:

A performance budget doesn’t guide your decisions about what content should be displayed. Rather, it’s about how you choose to display that content. Removing important content altogether to decrease the weight of a page is not a performance strategy. — Tim Kadlec, Setting a performance budget

In my case, this website is content-heavy (more text than images) and so it makes sense to include the following metrics as requirements into the performance budget:

  • Make the least number of network requests to load the content
  • Take the least amount of time to load the content once it’s in the user’s browser
  • Use local font fallbacks to load missing content without affecting the above two metrics

I initially started out with just using the variable fonts of Inter and Newsreader as they came from Google Fonts. But that would mean serving ~840KB of data in fonts alone. Aggressive caching strategies by my hosting provider (Vercel) would mean you would not download this font every time you loaded my website, but the defaults usually mean you’d likely download it every 31 days or so.

So, I decided to focus on the parts in my control: choosing variable and WOFF2 fonts, and then use font subsetting to further reduce the font size. My work to make this “tiny” optimization means the first time you load my website, instead of loading 840KB worth of fonts, you load only 80KB in fonts.


Taking this further with automatic font fallback based on font metrics

A fallback font is a font face that is used when the primary font face is not loaded yet, or is missing glyphs necessary to render page content. This article dives deep into font fallbacks, but the gist is that if you’re going to enable font fallbacks using font metrics, then you’re better off automating this process instead of figuring out the font metric override values yourself.

  • Astro: You can use @unjs/fontaine to automatically use a fallback font based on font metrics

  • Next.js: Starting in Next 13, next/font automatically uses font metric overrides and size-adjust to provide matching font fallbacks

  • Nuxt: Starting in Nuxt 3, you can use @nuxtjs/fontaine to automatically generate and insert matching font fallbacks into the stylesheets used by your Nuxt app

This site is built using Astro, so I could do something like this:

import { defineConfig } from "astro/config";import { FontaineTransform } from "fontaine";export default defineConfig({  integrations: [],  vite: {    plugins: [      FontaineTransform.vite({        fallbacks: [          "ui-sans-serif",          "system-ui",          "sans-serif",          "Apple Color Emoji",          "Segoe UI Emoji",          "Segoe UI Symbol",          "Noto Color Emoji",        ],        resolvePath: id =>          new URL(            `./public/fonts/font-sans-serif-regular-subset.woff2`,            import.meta.url,          ),      }),      FontaineTransform.vite({        fallbacks: ["ui-serif", "Georgia", "Cambria", "Times New Roman"],        resolvePath: id =>          new URL(`./public/fonts/font-serif.woff2`, import.meta.url),      }),    ],  },});

With those changes, here’s what my font declaration looks like:

@font-face {  font-family: "Inter";  font-display: swap;  font-style: normal;  font-weight: 100 900;  unicode-range: U+0020-007F, U+00A0-00FF, U+0100-017F, U+2018, U+2019, U+201C,    U+201D;  src: url("/fonts/font-sans-serif-regular-subset.woff2") format("woff2");}@font-face {  font-family: "Inter override";  src: ui-sans-serif, system-ui, sans-serif, local("Apple Color Emoji"),    local("Segoe UI Emoji"), local("Segoe UI Symbol"), local("Noto Color Emoji");  ascent-override: 96.875%;  descent-override: 24.14772727%;  line-gap-override: 0%;}@font-face {  font-family: "Newsreader";  font-display: swap;  font-style: italic;  font-weight: 400;  unicode-range: U+0020-007F, U+00A0-00FF, U+0100-017F, U+2018, U+2019, U+201C,    U+201D;  src: url("/fonts/font-serif.woff2") format("woff2");}@font-face {  font-family: "Newsreader override";  src: ui-serif, local("Georgia"), local("Cambria"), local("Times New Roman");  ascent-override: 73.5%;  descent-override: 26.5%;  line-gap-override: 0%;}

This was a fun little experiment that panned out well

PageSpeed Insights results showing a First Contentful Paint of 1.1 seconds, a Largest Contentful Paint of 1.4 seconds, and a Cumulative Layout Shift score of 0, which is the lowest (and best) possible score

This is the PageSpeed result of this very post you’re reading. Don’t get me wrong: a score of 100 would have been possible without any of the optimizations made in this post. However, when it comes to the performance cost of custom web fonts, if you’ve already exhausted all other optimization techniques (like using variable fonts, self-hosting, or using local fonts), then I think this might be a worthwhile experiment for you, too.