How to automate font subsetting in a build pipeline
- Step 1Decide the character set — Either a fixed language preset (Latin, Latin-Ext, Cyrillic…) or — better for marketing sites — the exact glyphs your pages use, discovered by scanning the built HTML with `glyphhanger`. Verify coverage with the [Character Coverage Map](/font-tools/character-coverage-map) before you cut.
- Step 2Pick an engine that preserves layout — `pyftsubset` (fontTools, Python) with `--layout-features='*'`, or harfbuzz `hb-subset` (what the JAD runner and `subfont` use, no Python). Both keep `GSUB`/`GPOS` so kerning and ligatures survive — opentype.js-based subsetters drop them.
- Step 3Wire it into the build — Add a prebuild/postbuild step that subsets each font and writes WOFF2 into your assets dir. Make it idempotent (skip fonts whose source + charset are unchanged) so it doesn't slow every build.
- Step 4Gate on a size budget — Fail CI if any output WOFF2 exceeds a threshold (e.g. 30 KB for a Latin subset). This catches the regression where someone widens the charset or swaps in an unsubsetted font.
Subsetting engines compared
All three preserve OpenType layout. The difference is the runtime they need and how you drive them.
| Engine | Runtime | Preserves GSUB/GPOS? | Best for |
|---|---|---|---|
pyftsubset (fontTools) | Python | Yes (--layout-features='*') | The reference; richest control over which tables/features to keep |
hb-subset (harfbuzz) | C / WASM | Yes | Fast, no Python; the engine under subfont and the JAD runner |
glyphhanger | Node + Python | Yes (calls pyftsubset) | Auto-discovering used glyphs by crawling your pages |
subfont | Node (no Python) | Yes (uses hb-subset) | Whole-site automation: scans HTML, subsets, rewrites CSS |
| JAD runner API | Node (WASM) | Yes (hb-subset) | Calling subsetting from a script without installing a toolchain |
Common pyftsubset flags
The flags that matter most for a web build. --flavor=woff2 needs the brotli Python package.
| Flag | What it does |
|---|---|
--unicodes=U+0020-00FF | Keep this Unicode range (Latin Basic + Latin-1). |
--text-file=used.txt | Keep only the characters in this file (pair with glyphhanger output). |
--layout-features='*' | Keep all OpenType layout features — preserves kerning, ligatures, stylistic sets. |
--flavor=woff2 | Output WOFF2 instead of TTF (requires brotli). |
--desubroutinize | Flatten CFF subroutines — sometimes smaller for OTF/CFF fonts. |
--no-hinting | Drop hinting instructions for extra size savings (fine for modern rendering). |
Cookbook
Copy-paste starting points for each approach. Adapt paths and the charset to your project.
pyftsubset — one font, Latin subset, WOFF2
ExampleThe fontTools reference command. pip install fonttools brotli first.
pyftsubset Inter.ttf \ --unicodes=U+0020-00FF \ --layout-features='*' \ --flavor=woff2 \ --output-file=Inter.latin.woff2
glyphhanger — discover used glyphs from built pages, then subset
ExampleCrawls your output HTML, collects the Unicode actually used, and subsets via pyftsubset. Great for marketing sites with fixed copy.
npx glyphhanger ./dist --subset='./src/fonts/*.ttf' --formats=woff2 --LATIN
subfont — whole-site, no Python (hb-subset under the hood)
ExampleRuns as a post-build step: subsets every font to the glyphs used and rewrites your @font-face CSS to point at the new WOFF2s.
npx subfont ./dist/index.html --in-place --inline-fonts=false
GitHub Actions — subset + size-budget gate
ExampleSubset in CI and fail the build if the result exceeds a budget. Catches charset/regression mistakes before they ship.
- name: Subset fonts
run: |
pip install fonttools brotli
pyftsubset src/fonts/Inter.ttf --unicodes=U+0020-00FF \
--layout-features='*' --flavor=woff2 \
--output-file=dist/fonts/Inter.latin.woff2
- name: Enforce size budget
run: |
SIZE=$(stat -c%s dist/fonts/Inter.latin.woff2)
echo "Inter.latin.woff2 = $SIZE bytes"
test "$SIZE" -le 30720 # fail if over 30 KBJAD runner HTTP API — subset from a script, no toolchain install
ExampleIf you run the JAD runner, POST a font and a charset to its local tools endpoint; it subsets with hb-subset (layout preserved) and returns WOFF2. Useful when you don't want Python or native deps in CI.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/font-subsetter/run \
-F 'file=@src/fonts/Inter.ttf' \
-F 'inputs={"charset":"latin","format":"woff2"}' \
-o dist/fonts/Inter.latin.woff2Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Kerning or ligatures disappear after subsetting
engine choiceYou used a subsetter that drops GSUB/GPOS (e.g. an opentype.js-based one — including JAD's in-browser Font Subsetter, which notes this). Use pyftsubset --layout-features='*', hb-subset, or the JAD runner API instead — all preserve layout tables.
A glyph is missing on a page after deploy
charset too narrowThe subset didn't include a character your content uses (a curly quote, an em-dash, an accented name). Widen the charset, or use glyphhanger/subfont to derive it from the actual pages so nothing is missed.
Dynamic / user-generated text
don't over-subsetGlyph-usage scanning only sees build-time content. For user-generated text (comments, names in any language), subset to full language ranges (e.g. all of Latin-Ext + Cyrillic) rather than just the glyphs on the page, or you'll get tofu for real users.
CFF/OTF font won't subset cleanly
use pyftsubset/hb-subsetopentype.js-based writers struggle with CFF (PostScript) outlines. pyftsubset and hb-subset handle CFF and CFF2 properly — another reason to keep those in the pipeline rather than a JS-only writer.
Build got slower after adding subsetting
make it idempotentCache by (font hash + charset) and skip unchanged fonts; only subset on change. Or restrict subsetting to production builds so local dev stays fast.
Variable font subsetting
supported, carefullyhb-subset and pyftsubset can subset variable fonts and even pin/instance axes. Decide whether you want to keep the full axis range (flexibility) or instance a single weight (smaller) — they're different commands.
Frequently asked questions
Does subsetting keep kerning and ligatures?
It depends entirely on the engine. harfbuzz hb-subset (used by subfont and the JAD runner) and pyftsubset --layout-features='*' preserve GSUB/GPOS, so kerning, ligatures, and stylistic sets survive. opentype.js-based subsetters — including JAD's in-browser tool — drop those tables; the browser tool says so explicitly. For a build pipeline, use a layout-preserving engine.
What's the simplest no-Python option?
subfont (Node, uses hb-subset under the hood) for whole-site automation, or the JAD runner's HTTP API if you'd rather call a single endpoint per font. Both avoid installing Python/fontTools and still preserve layout tables. glyphhanger is excellent but shells out to pyftsubset, so it needs Python.
How do I know which characters to keep?
Two strategies. For fixed copy (marketing sites), scan the built HTML with glyphhanger or subfont to collect exactly the glyphs used. For dynamic or multilingual content, subset to whole language ranges (Latin, Latin-Ext, Cyrillic…) so real users don't hit missing glyphs. Run the Character Coverage Map to confirm the source font even has what you need.
How much smaller will the font get?
For an English-only Latin subset, typically 60–95% smaller than the full font — a 200 KB TTF often drops to 15–25 KB as WOFF2. CJK fonts subset to a common-character set go from multiple megabytes to a few hundred KB. The exact figure depends on how many glyphs you keep. See Subset a font to shrink WOFF2 by 60–90%.
Should I subset to a fixed range or to used glyphs?
Used-glyph subsetting (glyphhanger/subfont) is smallest and ideal for static content. Fixed-range subsetting is safer for anything dynamic — user names, comments, CMS content — because it won't break on a character that wasn't on the page at build time. Many sites do both: used-glyphs for headings, language-range for body copy.
Can I run this in GitHub Actions / GitLab CI?
Yes — that's the point. Add a build step that runs your chosen subsetter and writes WOFF2 into the assets directory, then a check that fails if any output exceeds a size budget. The cookbook above has a working GitHub Actions snippet using pyftsubset plus a 30 KB gate.
How does this differ from the JAD in-browser Font Subsetter?
The in-browser tool is a fast manual one-off and uses opentype.js, which drops layout tables. This guide is about automating subsetting in CI with layout-preserving engines (hb-subset/pyftsubset) — including the JAD runner's API, which uses hb-subset and keeps kerning/ligatures. Use the browser tool to experiment; use the pipeline for production.
What output format should I ship?
WOFF2 — it's the smallest and supported by every modern browser. Only add a WOFF 1.0 fallback if your analytics show meaningful IE11 traffic. See WOFF & WOFF2 internals for the format details.
Will subsetting break my variable font?
No, if you use hb-subset or pyftsubset — both support variable fonts. You can keep the full axis range or instance a specific weight to shrink further. Just decide which you want: a flexible variable subset, or a smaller pinned instance.
How do I make the build reproducible?
Pin your subsetter version, fix the charset (or commit the glyphhanger output), and the same source font will always produce the same bytes — which keeps long-term caching effective. Cache by font-hash + charset so unchanged fonts aren't re-subset on every build.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.