How to font subsetting tradeoffs and known limitations
- Step 1Confirm source coverage before you cut — Run the [Character Coverage Map](/font-tools/character-coverage-map) on the source — it scores against 346 Unicode blocks so you know which presets are even valid. Subsetting to a charset the font lacks throws `Subset would be empty`.
- Step 2Decide whether layout features matter for this font — If it's a logo, heading, or simple body face, the dropped `kern`/`GSUB` won't show. If it's a script or display face that relies on ligatures and kerning, plan to use a layout-preserving engine instead — the in-browser tool will strip them.
- Step 3Subset and read the note in the metrics — The result panel reports glyphs in source, glyphs kept, sizes, and an explicit `Kerning + OT layout features dropped; outlines preserved` note. That note is your confirmation of the trade, not a warning of failure.
- Step 4Audit kerning if it's display type — Run the [Kerning Pair Auditor](/font-tools/kerning-pair-auditor) on the subset. Because the Subsetter drops `kern`/`GPOS`, expect the audit to show no pairs — that's the confirmation you need to decide whether to re-cut with a layout engine.
- Step 5Check the features you depend on — If your design uses small caps (`smcp`), standard ligatures (`liga`) or stylistic sets, run the [OpenType Features Inspector](/font-tools/opentype-features-inspector) on the subset. Expect them gone — use it to confirm and switch engines if they're required.
- Step 6Verify the output still covers your content — Re-run the [Coverage Map](/font-tools/character-coverage-map) on the subset. The blocks your content needs should still register; anything missing means the preset was too narrow and those characters will tofu.
Survival matrix — what the opentype.js writer keeps and drops
Grounded in subsetByCodepoints in font-processor.ts. 'Dropped' means the table is not written into the rebuilt font, not 'reduced'.
| Source feature / table | After JAD subset | Why |
|---|---|---|
Glyph outlines (glyf) | Preserved | Outlines for kept codepoints are copied into the new font |
cmap (char → glyph) | Preserved (for kept glyphs) | Rebuilt from the kept set |
.notdef glyph 0 | Preserved (always) | Required; explicitly pushed first |
Kerning (kern, GPOS) | Dropped | Writer doesn't round-trip layout tables |
Ligatures / alternates (GSUB) | Dropped | Same — layout tables are not rebuilt |
Variable axes (fvar/gvar/avar) | Dropped | Output is a single static style |
Colour layers (COLR/CPAL/sbix) | Dropped → monochrome | Only outline data is rebuilt |
Hinting (fpgm/prep/cvt) | Dropped | Not carried by the outline rebuild |
| Family / style name | Preserved | Read via getEnglishName and re-applied |
| Container format | TTF (even from OTF/CFF) | Writer prefers TrueType outlines |
When to use the in-browser tool vs a CI engine
The trade is acceptable for static text and unacceptable where layout features are load-bearing. Pick the engine to match.
| Use case | In-browser JAD Subsetter | CI engine (pyftsubset / hb-subset) |
|---|---|---|
| Logo / wordmark (fixed glyphs) | Ideal — smallest, fastest | Overkill |
| Simple Latin body / UI text | Fine — kerning loss rarely visible | Use if kerning is precise-critical |
| Display headings in a script font | Risky — ligatures/kerning gone | Use this — --layout-features='*' |
| Variable font, keep axes | No — collapses to one style | Use this — supports axis pinning/instancing |
| CJK / Arabic / Indic | No preset + shaping needs layout | Use this |
| Colour / emoji font, keep colour | No — strips colour to mono | Use a colour-aware tool |
Cookbook
Verification recipes — how to confirm what survived and catch the silent failures before they reach production.
The metrics note that confirms the trade
ExampleEvery subset result spells out what happened. The note is informational — it always appears, because the trade is structural, not conditional.
Result panel after subsetting Inter to Latin: Charset: latin Glyphs in source: 2,548 Glyphs kept: 191 (190 matched + .notdef) Source: 312.0 KB Subset: 58.4 KB Note: Kerning + OT layout features dropped; outlines preserved
Proving kerning was dropped
ExampleRun the Kerning Pair Auditor on the subset. An empty result is expected and is your decision point: if those pairs mattered, re-cut with a layout-preserving engine.
Before subset: Kerning Pair Auditor → 412 GPOS pairs (e.g. AV -84, To -120) After JAD subset: Kerning Pair Auditor → 0 pairs If this font is body text: ship it, the loss is invisible. If it's a display heading: re-subset with pyftsubset --layout-features='*' to keep the pairs.
CFF/OTF input returns a TTF
ExampleAn OTF with PostScript (CFF) outlines is read fine but written as TrueType. Browsers render it identically; the file extension and outline type change.
Input: SourceSerif.otf (CFF / PostScript outlines) JAD Subsetter, Charset=Latin Output: SourceSerif.latin.ttf (TrueType outlines) Visually identical in-browser. If you specifically need to keep CFF outlines, subset with pyftsubset/hb-subset instead.
The writer throws on a stubborn CFF font
ExampleSome CFF/CFF2 or non-standard-table fonts make the opentype.js writer fail outright rather than convert. The error tells you the fix.
Input: ProblemFont.otf (CFF2 + unusual tables) JAD Subsetter Error: Subsetting failed in opentype.js's font writer (<message>). This typically affects fonts with CFF (PostScript) outlines or non-standard tables. Convert to TTF first using a desktop tool, or use the Character Whitelist Builder with a smaller charset.
Catching tofu before deploy
ExampleA too-narrow charset doesn't error if SOME glyphs match — it silently omits the rest, which render as .notdef boxes. Re-run the Coverage Map to catch it.
Content uses curly quotes (U+2018/2019) and an em-dash (U+2014).
Subset with Charset=Latin (U+0020-00FF) — those codepoints
are OUTSIDE Latin-1, so they're dropped silently.
Coverage Map on the subset → 'General Punctuation' block: 0%
Result on page: " " and — render as □ boxes.
Fix: use the Symbols preset too, or the Whitelist Builder
with the exact punctuation included.Edge 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 disappears entirely
ExpectedThe opentype.js writer does not round-trip kern or GPOS, so the subset has zero kerning pairs — not 'reduced', gone. Invisible in most body/UI text; visible as loose spacing in display type. Confirm with the Kerning Pair Auditor and switch to a layout-preserving engine if the pairs matter.
fi / fl ligatures stop joining
ExpectedStandard ligatures live in GSUB, which is dropped. An f+i sequence renders as two separate glyphs instead of the fi ligature. For most sans-serifs this is unnoticeable; for serif/display faces where ligatures are part of the look, subset with pyftsubset --layout-features='*' and verify in the Features Inspector.
Variable font collapses to one weight
ExpectedThe output is a single static style built from unitsPerEm/ascender/descender plus outlines — fvar/gvar axes are not written. Freeze the instance you want with the Variable Font Freezer first, then subset, or use a CI engine that supports axis pinning.
Colour emoji becomes black and white
ExpectedCOLR/CPAL/sbix colour layers are not rebuilt, so a colour font returns as monochrome outlines. Fine if you wanted mono; if you need colour preserved, the in-browser Subsetter is the wrong tool — don't subset colour fonts here.
OTF comes back as TTF
By designopentype.js reads CFF (PostScript) outlines but its writer prefers TrueType, so a .otf input returns a .ttf. Rendering is identical in browsers. If you must keep CFF outlines (e.g. for a print pipeline), subset with pyftsubset/hb-subset instead.
Writer throws on a CFF/non-standard font
errorSome CFF/CFF2 fonts or those with unusual tables make the writer fail: Subsetting failed in opentype.js's font writer. Convert to TTF on the desktop first, narrow the charset via the Character Whitelist Builder, or move the job to a CI engine that handles CFF natively.
Empty subset for a mismatched charset
errorIf no glyph matches the chosen preset (e.g. Greek on a Latin-only font), the tool refuses with Subset would be empty and points you to the Coverage Map, rather than emitting a broken .notdef-only file. Pick a preset the font actually contains.
Silent tofu from a too-narrow charset
Coverage gapIf only SOME of your content's codepoints fall in the chosen preset, the rest are dropped with no error and render as .notdef boxes. Curly quotes and em-dashes fall outside the Latin preset, for instance. Re-run the Coverage Map on the subset to catch gaps before deploy.
Hinting and bitmap tables don't survive
ExpectedThe outline rebuild doesn't carry fpgm/prep/cvt hinting or embedded bitmap tables. On modern high-DPI rendering the loss of hinting is generally fine; on legacy low-DPI Windows it can soften small text. If hinting matters, subset with an engine that preserves it.
Output TTF looks bigger than the source
By designThe subset is uncompressed TTF. If your source was already a compressed WOFF2, the intermediate TTF can be larger on disk. That's expected — run TTF→WOFF2 and the final file lands well below the original.
Frequently asked questions
Why might ligatures break after subsetting?
Standard ligatures (fi, fl) live in the GSUB table, which the opentype.js writer drops entirely during subset. The visible symptom is an f+i sequence showing as two glyphs instead of the joined fi. To keep ligatures, subset with pyftsubset --layout-features='*' or hb-subset and confirm with the OpenType Features Inspector.
Is kerning 'reduced' or fully dropped?
Fully dropped. The writer doesn't round-trip kern or GPOS, so the subset has no kerning at all — running the Kerning Pair Auditor on the result returns zero pairs. For body and UI text this is rarely noticeable; for display type it is, so plan accordingly.
What about COLR/CPAL chromatic glyphs?
Colour layers are stored in COLR/CPAL (and sbix for bitmap colour), none of which are rebuilt — the result is a monochrome version of your colour font. If monochrome is the goal that's convenient; if not, don't subset colour fonts with this tool. The Colour Table Remover does the same strip deliberately.
Can I subset OTF (CFF) fonts safely?
Usually — opentype.js reads CFF but its writer emits TrueType outlines, so the result is functionally correct and renders identically, just as a .ttf. Some CFF/CFF2 or non-standard fonts make the writer throw instead; convert to TTF first, narrow the charset, or use a CI engine that handles CFF natively.
Does subsetting keep variable-font axes?
No. The output is a single static style — fvar/gvar/avar are not written. Use the Variable Font Freezer to pin the instance you want before subsetting, or subset with a CI engine that supports axis pinning/instancing if you need to keep the axes.
What's the limit on glyphs?
Free tier keeps up to 1,000 glyphs (covers most Latin/Cyrillic/Greek presets), Pro up to 65,536, and Developer is unlimited. File-size caps are 5 MB (free) and 50 MB (Pro). Large CJK subsets push past the free glyph ceiling, which is another reason CJK belongs in a CI engine.
Why is the output a TTF instead of WOFF2?
The Subsetter always writes TTF; WOFF2 compression is a separate step. Run the result through TTF→WOFF2 for the web deliverable. The uncompressed intermediate TTF can look larger than a compressed source — that's expected and resolves after compression.
What's the silent failure mode to watch for?
Tofu. If your content uses codepoints outside the chosen preset — curly quotes, em-dashes, an accented name — they're dropped without an error and render as .notdef boxes. Re-run the Character Coverage Map on the subset to confirm every block your content needs still registers.
When should I use fonttools/pyftsubset instead?
When layout features are load-bearing: display headings, script faces, complex scripts (Arabic, Indic, CJK), or any font where kerning/ligatures must survive — and when you need to keep variable axes or CFF outlines. pyftsubset --layout-features='*' and hb-subset preserve GSUB/GPOS. See the build-pipeline guide.
Does it preserve the font's family name?
Yes — the rebuilt font reads the source's English family and subfamily names via getEnglishName and re-applies them, falling back to the filename stem if absent. So your @font-face { font-family } references keep working after subsetting.
Is the .notdef box a bug?
No — .notdef (glyph 0) is intentionally always kept so any character outside your subset renders the font's missing-glyph box rather than crashing the shaper. Seeing the box is the signal your charset was too narrow; widen the preset or switch to the Character Whitelist Builder.
Will it strip hinting too?
Yes — the outline rebuild doesn't carry fpgm/prep/cvt hinting instructions. On modern and high-DPI rendering this is generally fine; on legacy low-DPI Windows, small text can soften slightly. If you specifically need to strip hinting and nothing else, use the dedicated Hinting Stripper; if you need to keep it, use a hinting-aware CI engine.
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.