glyphhanger vs pyftsubset: A Subsetting Workflow
This guide is part of the Unicode-Range & Font Subset Loading guide, itself under the Font Loading & Delivery Strategies area.
Most teams ship the full glyph set of a display font — often 800 to 3,000 glyphs covering Latin Extended, Cyrillic, Greek, and a scatter of symbols — when a given page renders fewer than 120 distinct characters. A typical unhinted TTF with a large glyph table runs 180–320 KB before compression; the same font trimmed to only the glyphs a page actually paints is routinely 12–30 KB as WOFF2. The problem isn't knowing that subsetting helps (see how unicode-range reduces font payload by 60–90%); it's building a workflow that discovers the correct glyph set automatically instead of guessing at a Unicode block. That's where glyphhanger and pyftsubset come in — they solve two different halves of the same problem, and confusing their roles is the single most common mistake in subsetting pipelines.
glyphhanger is a discovery tool: it crawls rendered pages (via a headless Puppeteer instance) and reports which characters actually appear in the DOM. It is not a subsetting engine on its own — its built-in --subset flag shells out to pyftsubset under the hood, and for anything beyond a single quick pass you get far more control by running the two tools as separate, explicit stages. pyftsubset, part of the fonttools Python package, is the actual subsetting engine: given a font file and a --unicodes or --text-file argument, it rewrites the glyf, loca, cmap, hmtx, and GSUB/GPOS tables to include only what you asked for, then re-flates or re-compresses the result. Treat glyphhanger as your "what glyphs does this page use" step and pyftsubset as your "now cut the font down to exactly that" step, and the pipeline becomes trivial to reason about and to automate in CI.
The table below summarizes the division of labor so you never have to guess which tool owns which job:
| Concern | glyphhanger | pyftsubset |
|---|---|---|
| Crawls rendered pages for real character usage | Yes (headless Puppeteer) | No |
Generates a unicode-range string |
Yes | No |
| Performs the actual glyph/table subsetting | Delegates internally | Yes (native) |
| Controls OpenType layout feature retention | No | Yes (--layout-features) |
| Instances/pins variable font axes | No | Yes, via the companion fonttools varLib.instancer |
| Emits WOFF2 with Brotli | Via delegated pyftsubset call | Yes (--flavor=woff2) |
| Best invoked from | A build step against a live/staging URL | A Makefile or CI step against a known glyph list |
Prerequisites
- Node.js 16+ and
npxavailable (glyphhanger is distributed as an npm package and a Python wrapper; the npm version is the one most teams use). - Python 3.8+ with
fonttoolsinstalled:pip install fonttools brotli zopfli. Thebrotliextra is required forpyftsubsetto emit WOFF2 output directly. - A representative crawl target: either a running dev server, a set of built static HTML files, or a list of production URLs. glyphhanger needs real rendered pages, not just source templates, because it accounts for JS-injected text, pseudo-element content, and CMS copy.
- The source font in TTF or OTF format (subsetting a WOFF2 you already built compounds compression artifacts — always subset from the original outline source).
Implementation
Step one is glyph discovery. Point glyphhanger at every URL pattern your font actually renders on — not just the homepage, since a blog post or product page can introduce characters (em dashes, accented names, currency symbols) the homepage never uses:
npx glyphhanger \
https://staging.example.com/ \
https://staging.example.com/blog/ \
https://staging.example.com/pricing/ \
--subset=Inter-Variable.ttf \
--formats=woff2 \
--whitelist=U+0020-007E \
--output-dir=./dist/fonts \
--spider --spider-limit=200
--subset=Inter-Variable.ttftells glyphhanger which font file to eventually subset (it still delegates to pyftsubset internally when this flag is present).--formats=woff2requests WOFF2 output.--whitelist=U+0020-007Eforce-includes the basic Latin printable range even if a crawled sample happened not to use every character (guards against punctuation absent from your crawl sample but present in user-generated content later).--spider --spider-limit=200makes glyphhanger follow same-origin links up to 200 pages instead of only inspecting the URLs you listed — useful for catching long-tail content pages.- Without
--subset, glyphhanger just prints the discovered character set and a ready-to-pasteunicode-rangestring, which is often what you want when you're hand-authoring@font-faceblocks rather than running a scripted pipeline.
Run it in "report only" mode first to sanity-check what it found before you let it mutate anything:
npx glyphhanger https://staging.example.com/ --spider --spider-limit=200
This prints output like U+0020-007E,U+2013-2014,U+2018-201D — the exact ASCII range plus en/em dashes and curly quotes your CMS renders. Save that string; it's your input to the explicit pyftsubset stage.
Step two is the explicit subset, run directly against fonttools so you control every table and compression flag rather than relying on glyphhanger's internal defaults:
pyftsubset Inter-Variable.ttf \
--unicodes="U+0020-007E,U+2013-2014,U+2018-201D" \
--layout-features="kern,liga,calt" \
--flavor=woff2 \
--output-file=Inter-Variable-subset.woff2 \
--desubroutinize \
--no-hinting
--unicodestakes the exact range glyphhanger reported — this is the handoff point between the two tools.--layout-features="kern,liga,calt"preserves kerning, standard ligatures, and contextual alternates; without this flagpyftsubsetstrips OpenType feature tables by default, which silently breaks ligatures likefi/flin body copy.--flavor=woff2emits WOFF2 directly with Brotli compression (see how WOFF2 Brotli compression shrinks fonts for what happens inside this step).--desubroutinizeinlines CFF charstring subroutines before subsetting, which avoids broken glyph outlines that can occur when a subroutine is referenced by a glyph you kept but shared with a glyph you dropped.--no-hintingstrips TrueType hinting instructions, which are irrelevant on the sub-pixel-antialiased rendering every modern browser uses and account for a measurable fraction of the pre-subset file size.
Compare the three sizes: unsubset TTF, glyphhanger's own quick subset, and the tuned pyftsubset output. A typical Latin-only pass on a 600-glyph variable sans looks like 285 KB → 34 KB → 19 KB, in that order, with the gap between the second and third numbers coming almost entirely from --desubroutinize and hinting removal.
Handling Variable Fonts and Multi-Script Pages
If the source is a variable font, add --drop-tables= overrides carefully — dropping STAT, fvar, or avar breaks axis registration, so only ever strip metadata tables like DSIG that don't affect rendering. For narrowing the axis range itself (pinning opsz or clamping wght before glyph subsetting), see subsetting variable fonts by axis and glyph — that's a separate, complementary reduction from the glyph-level trimming covered here. Run axis instancing first, then subset glyphs on the resulting static or reduced-axis instance; the two operations compose cleanly in that order but not in reverse, because instancing can remove glyph variants that a naive glyph subset would otherwise have preserved incorrectly.
For pages mixing Latin body copy with CJK headings or brand marks, run glyphhanger separately per script bucket and emit multiple @font-face declarations, each scoped with its own unicode-range so the browser only fetches the file matching characters actually present on the rendered page:
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Variable-latin.woff2") format("woff2-variations");
unicode-range: U+0020-007E, U+2013-2014, U+2018-201D;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter-Variable-cyrillic.woff2") format("woff2-variations");
unicode-range: U+0400-04FF;
font-display: swap;
}
A Defensive, Timeout-Bounded CI Variant
A crawl against a live staging server is a network dependency your build should never hang on indefinitely. Wrap the glyphhanger step with a hard timeout and fail the build loudly rather than silently shipping an un-subsetted font, and validate the reported character set isn't suspiciously empty (a common failure mode when the crawler hits a login wall or a JS error prevents text from rendering):
#!/usr/bin/env bash
set -euo pipefail
TIMEOUT_SECONDS=90
OUTPUT=$(timeout "${TIMEOUT_SECONDS}" npx glyphhanger \
https://staging.example.com/ https://staging.example.com/blog/ \
--spider --spider-limit=200 2>&1) || {
echo "glyphhanger crawl failed or exceeded ${TIMEOUT_SECONDS}s" >&2
exit 1
}
RANGE=$(echo "$OUTPUT" | grep -oE 'U\+[0-9A-F,\-]+' | tail -n1)
if [ -z "$RANGE" ] || [ "${#RANGE}" -lt 10 ]; then
echo "Discovered glyph range looks too small — refusing to subset: '$RANGE'" >&2
exit 1
fi
pyftsubset Inter-Variable.ttf \
--unicodes="$RANGE" \
--layout-features="kern,liga,calt" \
--flavor=woff2 \
--output-file=dist/fonts/Inter-Variable-subset.woff2 \
--desubroutinize --no-hinting
echo "Subset written: $(du -h dist/fonts/Inter-Variable-subset.woff2 | cut -f1)"
The ${#RANGE} -lt 10 guard catches the case where the crawl technically succeeded but returned almost nothing — a single glyph or an empty string — which would otherwise ship a font missing most of the alphabet without failing the pipeline. This pairs well with the broader CI setup in automating font subsetting in CI/CD pipelines, which covers caching the crawl results and diffing subset output between runs.
Verification
After building the subset, confirm three things in DevTools and on the command line:
- Glyph coverage. Open the built page in Chrome, then in DevTools run
document.fonts.check("16px Inter")in the console after the font has loaded — it returnstrueonce the browser has a matching, loaded face. To spot missing glyphs, render every character your CMS could plausibly emit (currency symbols, typographic quotes, em dashes) in a hidden test element and visually diff for.notdefboxes (tofu). - Transfer size. In the Network panel, filter by
Font, reload, and confirm the WOFF2 request'sSizecolumn (compressed transfer size, not decoded size) sits well under your budget — a Latin-only variable subset should land under 40 KB even for a font family spanning four weights on a single variable axis. cmaptable sanity. Runpyftsubset's companion toolttx -t cmap Inter-Variable-subset.woff2 -o -(viafonttools ttx) and confirm the character map only lists the code points you intended — an unexpectedly largecmapafter subsetting usually means a--unicodesargument was malformed and fonttools silently fell back to keeping everything.
Common Pitfalls
- Skipping
--layout-featuresand losing ligatures. Defaultpyftsubsetbehavior drops OpenType GSUB/GPOS features unless explicitly kept, sofiandflrender as two disconnected glyphs instead of a ligature — always pass--layout-features="kern,liga,calt"(or--layout-features='*'to keep everything, at the cost of a slightly larger file). - Crawling only the homepage. A glyphhanger pass against one URL misses characters that show up only in blog posts, user-generated content, or a pricing page's currency symbols, producing tofu in production weeks after a subset ships fine in review.
- Subsetting a WOFF2 instead of the source TTF/OTF. Re-subsetting an already-compressed WOFF2 compounds transform artifacts and can produce a larger file than subsetting the original outline source — always keep the un-subsetted TTF/OTF as your build input.
- Forgetting
--desubroutinizeon CFF-flavored fonts. Shared subroutines referenced across glyphs you dropped and glyphs you kept can produce corrupted outlines that render as blank or garbled glyphs in some browsers;--desubroutinizeinlines them before the cut. - No CI guard on crawl failures. A glyphhanger crawl that silently returns an empty or near-empty character set (login wall, JS error, staging server down) should fail the build, not ship a broken font — see the defensive script above.
Frequently Asked Questions
Do I need both tools, or can pyftsubset do the discovery itself?
pyftsubset has no crawling capability — it only subsets according to whatever --unicodes or --text you give it. You either supply that list by hand (fine for a fixed, small character set like a logotype) or generate it with glyphhanger's crawl. For any site with dynamic or CMS-driven copy, glyphhanger's automated discovery is what keeps the subset accurate as content changes.
Should I run glyphhanger's built-in --subset flag or call pyftsubset separately?
glyphhanger's --subset is convenient for a one-off pass, but it hard-codes a smaller set of pyftsubset flags than you likely want in production — notably it doesn't default to preserving ligature features. Running the two stages explicitly, as shown above, gives full control over --layout-features, --desubroutinize, and hinting removal.
How often should the crawl re-run? Re-run it whenever templates or copy change meaningfully — most teams wire it into the same CI job that rebuilds the site, gated behind a cache keyed on content hash so an unchanged site doesn't re-crawl on every commit. A stale subset only becomes visible as tofu, so treat crawl freshness as part of your regression suite rather than a manual step.
What happens if a user's browser needs a character outside the subset?
The glyph simply isn't in the font, so the browser falls back to the next family in your font-family stack for that character only — other characters on the same line still render in the web font. This is why a well-designed fallback font stack matters even after subsetting: it's your safety net for the rare code point the crawl didn't anticipate.