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.

Where the bytes go in a variable font A breakdown of a 128 kilobyte variable font: 31 kilobytes are glyphs the site renders, 58 kilobytes are Latin Extended and Cyrillic glyphs it never uses, 24 kilobytes are variation data for unused axes, and 15 kilobytes are hinting and metadata. Subsetting and axis pinning remove 97 kilobytes. 128 KB shipped, 31 KB used glyphs in use 31 KB unused glyph ranges 58 KB unused axes 24 KB metadata 15 KB After subsetting and pinning 31 KB total 97 KB removed — never sent, never parsed, never decoded Measured with fonttools on a 900-page English-language documentation site The unused-axis slice assumes weight is the only axis the design varies
Most of a variable font is coverage for languages and design variations a given site never uses. Neither is free: both are downloaded and both are parsed before the first glyph paints.

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.

Font subsetting as a build pipeline A pipeline: the built HTML feeds a charset extractor, which feeds pyftsubset along with the original font file, producing a subset WOFF2. A coverage check then compares the subset against the HTML and fails the build if any character is missing. Derived from content, verified against content built HTML every page charset + safety margin pyftsubset + axis pinning subset.woff2 31 KB coverage check — fails the build on a missing glyph compares the subset back against the same HTML
The loop closes on itself: the same HTML that produced the character set is what verifies the subset covers it, so a new character in a new article fails the build rather than the page.

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:

ConfigurationFont transferFirst render of styled textLCP
Full variable font, self-hosted128 KB1.42 s2.30 s
Subset to used glyphs47 KB0.94 s2.02 s
Subset + axis pinning (wght only)31 KB0.78 s1.94 s
Subset + pinning + preload31 KB0.61 s1.88 s
Font transfer size and time to styled text A horizontal bar chart on a shared scale. The full font transfers 128 kilobytes and styled text appears at 1.42 seconds. Subsetting drops it to 47 kilobytes and 0.94 seconds. Adding axis pinning gives 31 kilobytes and 0.78 seconds. Adding a preload gives 0.61 seconds at the same size. Bytes removed are milliseconds removed Full font 128 KB · 1.42 s Subset 47 KB · 0.94 s + axis pinning 31 KB 0.78 s + preload 31 KB 0.61 s · earliest styled paint Shared linear scale on transfer size · Chrome 4× CPU throttle, fast 3G, median of 5 runs
Axis pinning is the step teams skip. It removed a further 16 KB here for no visual change at all, because the design only ever used the weight axis.

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 calt or tnum produces 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-face src back 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.