Subsetting Variable Fonts for Faster First Render
A variable font is already an efficiency win: one file replaces five static weights. It is also usually much larger than it needs to be, because it ships every glyph in Latin Extended plus axis data for variations your design never uses. A typical 128 KB variable face carries perhaps 25 KB of glyphs your site actually renders.
Subsetting removes the rest. This guide derives the character set from your real content, generates the subset during the build, pins unused axes, and measures the effect on first render. It sits under Font Loading Strategies for Static Sites.
Prerequisites
- A self-hosted variable font — you cannot subset a file served from someone else's stylesheet.
- Python with
fonttools(pip install "fonttools[woff]" brotli), or a Node equivalent, available to the build. - The built HTML, so the character set can be derived from what you actually publish.
Derive the Character Set From the Content
Guessing the character set is how a site ends up rendering an em dash as a fallback glyph in one paragraph. Extract it from the build output instead:
// scripts/font-charset.mjs — writes the unique code points the site renders
import { readFileSync, writeFileSync } from 'node:fs';
import { globSync } from 'node:fs';
const chars = new Set();
for (const file of globSync('dist/**/*.html')) {
const text = readFileSync(file, 'utf8')
.replace(/<script[\s\S]*?<\/script>/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/<[^>]+>/g, ' ');
for (const ch of text) chars.add(ch);
}
// Safety margin: characters an author may add tomorrow
for (const ch of '€£¥©®™°±×÷–—…‘’“”«»†‡§¶←→↑↓⌘⏎✓✗│─└├') chars.add(ch);
const codepoints = [...chars]
.filter((c) => c.codePointAt(0) > 31)
.map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase())
.sort();
writeFileSync('build/charset.txt', codepoints.join(','));
console.log(`${codepoints.length} code points in use`);
The safety margin matters more than it looks. A site that publishes weekly will introduce a character the subset does not contain within a month, and the symptom — one glyph in a different typeface — is subtle enough to survive review.
Subset and Pin in the Build
#!/usr/bin/env bash
set -euo pipefail
CHARSET=$(cat build/charset.txt)
pyftsubset fonts/src/inter-var.ttf \
--unicodes="$CHARSET" \
--layout-features='kern,liga,calt,tnum' \
--variations='wght=400:700' \
--flavor=woff2 \
--desubroutinize \
--output-file=dist/fonts/inter-subset.woff2
ls -l dist/fonts/inter-subset.woff2
Three flags do the work. --unicodes restricts the glyph set. --layout-features keeps only the OpenType features you use — kerning, standard ligatures, contextual alternates and tabular numerals for tables — and drops the rest. --variations pins the weight axis to the 400-700 range and removes every other axis's interpolation data.
Be careful with features. Dropping calt breaks contextual alternates that some faces rely on for basic letterforms; dropping tnum makes numbers in tables jitter between rows. Keep the four above unless you have checked what the face uses.
Run it as a build step so the subset always matches current content:
{
"scripts": {
"prebuild": "node scripts/font-charset.mjs || true",
"build": "astro build && bash scripts/subset-fonts.sh",
"postbuild": "node scripts/check-font-coverage.mjs"
}
}
The order is deliberate: the charset script reads the previous build's HTML on a cold run, then the post-build coverage check catches anything new the current build introduced.
Verify Coverage Before Shipping
// scripts/check-font-coverage.mjs — fails the build on a missing glyph
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';
import * as fontkit from 'fontkit';
const font = fontkit.openSync('dist/fonts/inter-subset.woff2');
const missing = new Set();
for (const file of globSync('dist/**/*.html')) {
const text = readFileSync(file, 'utf8').replace(/<[^>]+>/g, ' ');
for (const ch of text) {
const cp = ch.codePointAt(0);
if (cp > 31 && !font.hasGlyphForCodePoint(cp)) missing.add(ch);
}
}
if (missing.size) {
console.error(`font subset is missing ${missing.size} character(s): ${[...missing].join(' ')}`);
process.exit(1);
}
console.log('font coverage: OK');
This check is the difference between subsetting being safe and being a slow-burning source of typographic bugs. It costs about a second and it fails the build with the exact characters that are missing.
Splitting Into Unicode Ranges
Subsetting to one file is right for a single-language site. When a site publishes in several scripts, a single subset is either huge or incomplete, and the better pattern is several subsets selected by unicode-range so browsers download only what a given page needs.
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+2018-201F, U+2020-2027, U+20AC;
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-024F, U+1E00-1EFF;
font-weight: 100 900;
font-display: swap;
}
The browser parses unicode-range before requesting anything and fetches only the files whose ranges the page actually uses. An English article downloads 31 KB; a Polish one downloads that plus a 9 KB extended file. Nobody downloads Cyrillic unless they read a Cyrillic page.
Two cautions. Ranges must not overlap, or a browser may fetch both files for the same character. And a page mixing scripts — an English article quoting a Greek term — will fetch two files, which is correct but worth knowing when a single page's font weight looks anomalous in a report.
For a site that is genuinely single-language, skip this. One file is simpler, and the second request's connection cost can outweigh the bytes saved on a short page.
Measured Impact
A 900-page English documentation site, one variable face, Chrome 4× CPU throttle on a fast 3G profile, median of five runs:
| Configuration | Font transfer | First render of styled text | LCP |
|---|---|---|---|
| Full variable font, self-hosted | 128 KB | 1.42 s | 2.30 s |
| Subset to used glyphs | 47 KB | 0.94 s | 2.02 s |
Subset + axis pinning (wght only) | 31 KB | 0.78 s | 1.94 s |
| Subset + pinning + preload | 31 KB | 0.61 s | 1.88 s |
Subsetting does not change the font's metrics, so any size-adjust and ascent-override values calculated for layout stability remain valid — the two techniques compose cleanly, as covered in Eliminating Layout Shift From Web Fonts.
Cache the Subset, Not the Step
Subsetting adds two to five seconds to a build, which is fine occasionally and annoying on every push. Key a cache on the font file and the character set so the step only runs when one of them changes:
- name: Cache font subsets
uses: actions/cache@v4
with:
path: dist/fonts
key: fonts-${{ hashFiles('fonts/src/*.ttf', 'build/charset.txt') }}
Serve the result with a content hash in the filename and an immutable cache header, so readers download it once and never revalidate it. Because the subset changes whenever the content introduces a new character, the hash is what keeps that correct without any manual versioning.
Pitfalls & Rollback
- A hand-written character set. It will be missing something within a month. Derive it from the built HTML.
- No coverage check. Missing glyphs render in a fallback face and are easy to miss in review. Fail the build instead.
- Dropping needed OpenType features. Losing
caltortnumproduces subtle rendering defects; keep the standard four unless you have verified otherwise. - Subsetting a font whose licence forbids modification. Check the licence — most webfont licences permit subsetting, but not all.
- Committing the subset. It drifts from the content. Generate it in the build and cache the artifact.
- Rollback: point the
@font-facesrcback at the full font file and remove the build step. Nothing else in the stylesheet changes, and metrics are identical either way.
Conclusion
Subsetting turns a variable font from one of the largest assets on the page into one of the smallest, and axis pinning removes a further slice most sites never notice they are paying for. Derive the character set from the built HTML, generate the subset during the build, keep the OpenType features the face relies on, and gate on a coverage check so a new character fails the build rather than the page. Then preload the result, and combine it with the metric overrides in Font Loading Strategies for Static Sites.
FAQ
How much smaller does subsetting make a variable font?
Typically 60 to 80 percent for a Latin site. A 128 kilobyte variable font with the full Latin Extended range drops to about 31 kilobytes when restricted to the characters a site actually publishes, and further if you pin unused axes.
What is axis pinning and when should I use it?
Pinning fixes a variable axis at one value and removes its interpolation data. If your design uses weights 400 and 700 but no optical size or width variation, pinning those axes removes bytes you were never going to use, without changing a single rendered glyph.
Will subsetting break characters I did not anticipate?
It will, unless the character set is derived from your actual content plus a safety margin. Derive it from the built HTML rather than guessing, and always include punctuation, currency symbols, arrows and the box-drawing characters code blocks tend to use.
Should the subset be generated in the build or committed?
Generate it in the build from the original font, and cache the result. That way the subset always matches the current content, and a new character in a new article never renders as a fallback glyph.
Does subsetting affect the metric-override values I set for layout stability?
No. Subsetting removes glyphs, not metrics — the ascent, descent and units-per-em are unchanged, so any size-adjust or ascent-override values you calculated remain correct.
Related
- Parent: Font Loading Strategies for Static Sites — the loading policy this feeds.
- Self-Hosting Google Fonts to Eliminate Layout Shift — the prerequisite for subsetting at all.
- Eliminating Layout Shift From Web Fonts — metric overrides that compose with this.
- Optimizing LCP on Astro With Priority Hints — deciding what to preload alongside the font.
- CDN Caching Rules for SSGs — caching the subset immutably.