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
Discovery-to-subset handoff A four-step pipeline: crawl pages with glyphhanger, capture the unicode range, subset with pyftsubset, verify the WOFF2 output. Discovery-to-subset handoff 1 Crawl pages glyphhanger --spider 2 Capture range U+0020-007E, ... 3 Subset font pyftsubset --unicodes 4 Verify output cmap + DevTools
glyphhanger discovers glyphs, pyftsubset performs the cut.

Prerequisites

  • Node.js 16+ and npx available (glyphhanger is distributed as an npm package and a Python wrapper; the npm version is the one most teams use).
  • Python 3.8+ with fonttools installed: pip install fonttools brotli zopfli. The brotli extra is required for pyftsubset to 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.ttf tells glyphhanger which font file to eventually subset (it still delegates to pyftsubset internally when this flag is present).
  • --formats=woff2 requests WOFF2 output.
  • --whitelist=U+0020-007E force-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=200 makes 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-paste unicode-range string, which is often what you want when you're hand-authoring @font-face blocks 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
  • --unicodes takes 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 flag pyftsubset strips OpenType feature tables by default, which silently breaks ligatures like fi/fl in body copy.
  • --flavor=woff2 emits WOFF2 directly with Brotli compression (see how WOFF2 Brotli compression shrinks fonts for what happens inside this step).
  • --desubroutinize inlines 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-hinting strips 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.

Font size at each pipeline stage Bar chart comparing font file size at three stages: original TTF, glyphhanger quick subset, and tuned pyftsubset output. Font size at each pipeline stage Original TTF 285 KB glyphhanger subset 34 KB Tuned pyftsubset 19 KB transfer size
Explicit pyftsubset flags cut file size well past glyphhanger's default subset.

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.

Per-script font-face layering Stack of font-face layers, one per script bucket, each scoped to its own unicode-range subset. Per-script font-face layering Inter Latin subset U+0020-007E, dashes, quotes Inter Cyrillic subset U+0400-04FF System fallback stack unmatched code points
Separate unicode-range-scoped files let the browser fetch only what a page needs.

Verification

After building the subset, confirm three things in DevTools and on the command line:

  1. 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 returns true once 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 .notdef boxes (tofu).
  2. Transfer size. In the Network panel, filter by Font, reload, and confirm the WOFF2 request's Size column (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.
  3. cmap table sanity. Run pyftsubset's companion tool ttx -t cmap Inter-Variable-subset.woff2 -o - (via fonttools ttx) and confirm the character map only lists the code points you intended — an unexpectedly large cmap after subsetting usually means a --unicodes argument was malformed and fonttools silently fell back to keeping everything.

Common Pitfalls

  • Skipping --layout-features and losing ligatures. Default pyftsubset behavior drops OpenType GSUB/GPOS features unless explicitly kept, so fi and fl render 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 --desubroutinize on 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; --desubroutinize inlines 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.

Related