How to font subsetting vs css unicode-range strategies
- Step 1Profile your traffic by language — If 90%+ of sessions render one language, a single subset file wins on simplicity. If you have meaningful split traffic (say 50/50 English/Russian), `unicode-range` splitting lets each visitor download only their script. This decision drives everything below.
- Step 2Subset the binary per language — Run the [Font Subsetter](/font-tools/font-subsetter) once per target charset — Latin, then Cyrillic, then Greek as needed. Each run keeps only that preset's codepoints, so the files don't overlap. Output is one TTF per language.
- Step 3Compress each subset to WOFF2 — Push every per-language TTF through [TTF→WOFF2](/font-tools/ttf-to-woff2). You end up with `brand-latin.woff2`, `brand-cyrillic.woff2`, etc. — independently cacheable files.
- Step 4Write one @font-face per file with a unicode-range — Each block points `src` at one WOFF2 and declares the `unicode-range` it covers (e.g. `U+0000-00FF` for Latin). The browser maps page characters to ranges and fetches only the matching files. The [Font-Face Generator](/font-tools/font-face-generator) emits these for you.
- Step 5Make the ranges non-overlapping — If two `@font-face` blocks both claim `A` (`U+0041`), the browser may fetch both files for a page that only has Latin. Keep each block's `unicode-range` tight to the script its file actually contains — match the subset you cut in step 2.
- Step 6Verify with DevTools — Load an English page and confirm only the Latin WOFF2 is requested in the Network tab. Switch to a Cyrillic page and confirm the Cyrillic file loads on demand. That on-demand behaviour is the whole point of the split.
Single subset file vs unicode-range split
Both start from the same subsetting step; they differ in how many files you ship and how the browser fetches them.
| Dimension | One subset file | unicode-range split (many files) |
|---|---|---|
| Best for | One dominant language (90%+ of traffic) | Genuinely multilingual traffic |
| Files shipped | 1 WOFF2 per weight | 1 WOFF2 per (weight × language) |
| Bytes per visitor | Whole subset, even unused parts | Only the ranges their page touches |
| @font-face blocks | One | One per range — more CSS to maintain |
| Cache invalidation | Editing any glyph busts the whole file | Granular — edit Cyrillic without busting Latin |
| Risk | Visitors download glyphs they never see | Overlapping ranges → double downloads |
How the JAD presets map to typical unicode-range declarations
The left column is the Subsetter dropdown value; the right is a sensible unicode-range to pair with each output file. Tighten further with the Font-Face Generator.
| Subsetter preset | Roughly covers | Typical unicode-range to declare |
|---|---|---|
| Latin | Latin Basic + Latin-1 | U+0000-00FF |
| Latin-Ext | Latin Extended-A/B + Additional | U+0100-024F, U+1E00-1EFF |
| Cyrillic | Cyrillic + Supplement | U+0400-052F |
| Greek | Greek + Extended | U+0370-03FF, U+1F00-1FFF |
| Symbols | Punctuation + currency | U+2000-206F, U+20A0-20CF |
Cookbook
Concrete CSS and decision recipes. The subsetting half is the JAD tool; the unicode-range half is the @font-face you ship.
Google Fonts' split, recreated for a self-hosted font
ExampleGoogle's CSS API emits one @font-face per subset, each with its own unicode-range. You can mirror it exactly with self-hosted subsets so swapping in is a no-CSS-change move.
@font-face {
font-family: 'Brand';
src: url('/fonts/brand-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;
}
@font-face {
font-family: 'Brand';
src: url('/fonts/brand-cyrillic.woff2') format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1;
}
/* English page → only brand-latin.woff2 is fetched. */The duplicate-glyph trap
ExampleTwo blocks both claim the basic Latin letters. A page with only English text triggers BOTH downloads because both ranges match — the opposite of what you wanted.
BAD — overlapping ranges:
@font-face { ...latin.woff2; unicode-range: U+0000-024F; }
@font-face { ...latin-ext.woff2; unicode-range: U+0000-024F; }
↑ both claim U+0041 'A' → English page fetches both files.
GOOD — disjoint ranges:
@font-face { ...latin.woff2; unicode-range: U+0000-00FF; }
@font-face { ...latin-ext.woff2; unicode-range: U+0100-024F, U+1E00-1EFF; }Single-file decision for a one-language site
ExampleA 95%-English SaaS doesn't need the split — the maintenance and the extra @font-face blocks aren't worth it. One subset, one block.
Traffic: 95% English, 5% other (falls back to system font fine)
Font Subsetter, Charset=Latin → brand.latin.ttf
TTF→WOFF2 → brand.latin.woff2 (18 KB)
@font-face {
font-family: 'Brand';
src: url('/fonts/brand.latin.woff2') format('woff2');
font-display: swap;
}
/* No unicode-range needed — one file, one language. */Split for a 50/50 English/Russian product
ExampleHere the split pays off: a Russian reader skips the Latin-only marketing glyphs they don't need and vice versa, and each language's file caches independently.
Font Subsetter ×2: Charset=Latin → brand-latin.ttf → brand-latin.woff2 (18 KB) Charset=Cyrillic → brand-cyrillic.ttf → brand-cyrillic.woff2 (22 KB) English visitor downloads: 18 KB Russian visitor downloads: 22 KB Neither downloads both (disjoint unicode-range).
Stacking three layers: instance + subset + unicode-range
ExampleThe smallest multilingual footprint pins a variable font to one weight, subsets each language, and splits by range. Note the freeze step is required because the Subsetter drops variable axes.
1. Variable Font Freezer → Brand-Regular.ttf (single weight) 2. Font Subsetter per language → latin.ttf, cyrillic.ttf, greek.ttf 3. TTF→WOFF2 on each 4. @font-face per file with disjoint unicode-range Result: a multilingual marketing site often totals <50 KB of font per visitor, because each only fetches their script's WOFF2.
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.
Overlapping unicode-range fetches the same file twice
Wasted bytesIf two @font-face blocks declare overlapping ranges, a page containing a shared codepoint triggers downloads of every matching file. Keep ranges disjoint and matched to the subset each file actually contains. This is the single most common reason a 'split' setup downloads more, not less.
unicode-range declared, but the file isn't actually subset
Wasted bytesunicode-range controls when a file downloads, not its size. Declaring U+0000-00FF on a full 300 KB multi-script font still ships 300 KB when an English page loads. The CSS layer and the binary layer are independent — you must subset the file too.
Browser too old for unicode-range
SupportedSupport is universal in modern browsers (Chrome 36+, Firefox 44+, Safari 10+, Edge 17+) — effectively 99%+ of traffic. Browsers without it simply download every declared @font-face file regardless of range, which wastes bandwidth but renders correctly. No tofu, just more bytes for a vanishing share of users.
Split font lost its kerning and ligatures
ExpectedBecause the JAD Subsetter rebuilds outlines only, each per-language file has no kern/GPOS/GSUB. For body and UI text this is usually invisible; for display headings use a layout-preserving engine (see the build-pipeline guide) before splitting.
Same family name across ranges but mismatched weights
Rendering bugAll your split files must share font-family, font-weight and font-style or the browser treats them as different faces and may not compose them across ranges. Generate the blocks consistently — the Font-Face Generator keeps the descriptors aligned.
A character falls outside every declared range
FallbackIf a page renders a codepoint no @font-face block claims (an emoji, an unexpected accented name), the browser falls back to the system font for that glyph — no download, possibly a visual mismatch. For user-generated text, declare wide ranges or accept system fallback rather than chasing every codepoint.
Too many tiny files hurt more than they help
Over-splitSplitting into a dozen micro-ranges adds connection/overhead per file and more CSS to maintain. For most sites, 2–4 script files is the sweet spot. Don't split Latin into 'basic' and 'punctuation' — the saving is noise next to the request overhead.
unicode-range used to swap fonts, not just languages
SupportedA valid advanced pattern: map a different typeface to a specific range (e.g. a custom numerals font over U+0030-0039). This works because unicode-range is per-codepoint, but it's easy to create overlaps — test carefully in DevTools that the right file wins for each glyph.
Frequently asked questions
Is unicode-range a replacement for subsetting?
No — they're complementary layers. Subsetting shrinks the file's bytes; unicode-range controls when the file downloads. unicode-range on a non-subset file just delays a still-huge download. For the smallest footprint you subset the binary and declare a tight unicode-range on each file.
Does unicode-range work in older browsers?
Yes, since Chrome 36, Firefox 44, Safari 10 and Edge 17 — about 99%+ of global traffic as of 2026. Browsers that lack support download every declared @font-face file regardless of range, so content still renders; they just use more bandwidth.
How does Google Fonts split its subsets?
Google's CSS API emits one @font-face per subset (latin, latin-ext, cyrillic, greek, vietnamese, etc.), each with its own unicode-range. The browser fetches only the subset whose codepoints appear on the page. You can recreate this exactly with self-hosted JAD subsets so swapping away from Google needs no CSS change.
Can I combine subsetting with unicode-range?
Yes, and you should for multilingual sites: subset the binary per language with the Font Subsetter, compress each, then declare a disjoint unicode-range per file. Stack a variable-font freeze on top and a multilingual marketing site often totals under 50 KB of font per visitor.
What's the duplicate-glyph trap?
When two @font-face blocks declare overlapping ranges, any page containing a shared character downloads both files — more bytes, not fewer. Keep each block's unicode-range disjoint and aligned to the actual subset it contains. The Font-Face Generator helps you keep them clean.
Do I need separate files per weight too?
Yes — a unicode-range split multiplies by weight. Three languages × three weights = nine files. That's a lot of CSS, so most teams ship 2–3 weights and 2–3 languages. Because the Subsetter outputs static styles, freeze each weight you need before subsetting.
Will the split preserve kerning?
Not with the in-browser Subsetter — it drops kern/GPOS/GSUB per file. For body and UI copy that's fine. If your headings need real kerning, subset with a layout-preserving engine first (see the build-pipeline guide), then apply the same unicode-range split.
How do I tell which files a page actually fetched?
Open DevTools → Network and filter to Font. Load an English page; only the Latin WOFF2 should appear. Switch to a Cyrillic page; the Cyrillic file should load on demand. If both load for one language, you have overlapping ranges.
What range should I declare for the JAD Latin preset?
U+0000-00FF matches the Subsetter's Latin output (Latin Basic + Latin-1). For Latin-Ext add U+0100-024F, U+1E00-1EFF. The mapping table above lists each preset's typical range; tighten further with the Font-Face Generator if you want to match Google's exact boundaries.
Is there a downside to too many subsets?
Yes — each file adds request overhead and CSS to maintain, and tiny ranges can cost more in connection overhead than they save in bytes. Two to four script files is the practical sweet spot for most sites.
What happens to characters outside every range?
The browser renders them in the system fallback font with no extra download. That's fine for the occasional emoji or stray symbol, but for content where coverage matters (names, multilingual UGC) declare wide enough ranges or accept the fallback look.
Where does the design-system version of this live?
See Subset Fonts for a Multilingual Design System for the per-locale inventory, fallback chains and cache strategy that turn this two-layer approach into a maintainable system.
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.