How to deploy latin filter as a default for i18n sites
- Step 1Audit your traffic by language — Pull the language breakdown from analytics. If 80–95% is English + Western European, a Latin-filtered default is the right base. Note which non-Latin scripts appear (Russian, Greek, Vietnamese, CJK) and at what share — those become your on-demand layers.
- Step 2Generate the Latin base subset per weight — Run each weight/style of your brand font through the [Latin Filter](/font-tools/latin-filter) → TTF, then [ttf-to-woff2](/font-tools/ttf-to-woff2). For a whole family in CI, automate with the [batch-script guide](/font-tools/guides/latin-filter-batch-script). Remember the Latin preset is fixed at `U+0020–U+00FF`.
- Step 3Generate the extended-script subsets — For each non-Latin script in your traffic, use [font-subsetter](/font-tools/font-subsetter) with the matching preset (`latin-ext`, `cyrillic`, `greek`, `vietnamese`). These also output TTF — compress each to WOFF2. If your font lacks a script entirely, you'll need a different typeface for it; check with [character-coverage-map](/font-tools/character-coverage-map).
- Step 4Wire layered @font-face rules with unicode-range — Declare one `@font-face` per (weight × script), all sharing the same `font-family` name but with distinct `src` and `unicode-range`. The browser downloads only the subsets whose ranges intersect the page's text — see the cookbook for the exact CSS.
- Step 5Set font-display and a system fallback — Use `font-display: swap` so text renders immediately in the fallback while the subset loads. Pair the brand `font-family` with a [system-font-stack](/font-tools/system-font-stack-generator) so missing scripts still read in a sensible OS font rather than a serif default.
- Step 6QA each locale for box-glyphs — Load a representative page per locale and scan for `□`. The classic miss is French `œ` (needs latin-ext, not latin) and Vietnamese `₫`. Confirm the right subset actually fired in the Network tab — if the Latin file loaded for a Cyrillic page, your `unicode-range` values are wrong.
Layered subset plan for a typical i18n site
A Latin-first deployment for a site whose traffic is mostly Western with Russian and Greek tails. Each row is one or more @font-face rules sharing the family name. Generate with the listed tool, then compress to WOFF2.
| Layer | unicode-range | Generated with | Loaded when |
|---|---|---|---|
| Latin base (default) | U+0000-00FF | Latin Filter | Always (every page has ASCII) |
| Latin Extended | U+0100-024F, U+1E00-1EFF | font-subsetter latin-ext | French œ, Polish, Czech, Turkish pages |
| Cyrillic | U+0400-04FF | font-subsetter cyrillic | Russian / Ukrainian / Bulgarian pages |
| Greek | U+0370-03FF | font-subsetter greek | Greek pages |
| Vietnamese | U+0100-024F, U+1E00-1EFF, U+20AB | font-subsetter vietnamese | Vietnamese pages |
Payload comparison: monolithic vs Latin-first
Illustrative for one weight of a multi-script sans. The Latin-first row is what a Western-language visitor actually downloads; non-Latin visitors fetch their own subset on top.
| Strategy | What a Latin visitor downloads | What a Russian visitor downloads |
|---|---|---|
| Monolithic multi-script WOFF2 | ~250 KB – several MB (whole font) | Same whole font |
| Latin-first, layered | ~16 KB (Latin subset only) | ~16 KB Latin + ~14 KB Cyrillic = ~30 KB |
| Latin-first, no Cyrillic layer (bug) | ~16 KB | ~16 KB — Cyrillic text boxes out |
Production caveats of the in-browser Latin Filter
What you trade for the one-click subset, and the mitigation for each in a deployment context.
| Caveat | Impact in deployment | Mitigation |
|---|---|---|
| Outputs TTF (uncompressed) | TTF is the wrong wire format | Always run ttf-to-woff2 before serving |
| Drops GPOS/GSUB (kerning, ligatures) | Display headlines lose kerning | Use harfbuzz subsetter for headline weights; body text is usually fine |
| Latin preset is U+0020–U+00FF only | French œ, CEE letters box out | Add a latin-ext layer via font-subsetter |
| One file per pass (no batch in UI) | Tedious across 5–9 weights × scripts | Automate per batch-script guide |
Cookbook
Copy-pasteable layered @font-face patterns. Generate each subset with the noted tool, compress to WOFF2, and let unicode-range route the downloads.
Two-layer base: Latin + Latin Extended
ExampleThe minimum i18n-aware setup. Everyone gets the small Latin file; pages with French œ or CEE letters additionally pull the Extended file. Both share the same family name.
@font-face {
font-family: "Brand";
font-weight: 400;
font-display: swap;
src: url(/f/brand-400.latin.woff2) format("woff2");
unicode-range: U+0000-00FF;
}
@font-face {
font-family: "Brand";
font-weight: 400;
font-display: swap;
src: url(/f/brand-400.ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+1E00-1EFF;
}
body { font-family: "Brand", -apple-system, "Segoe UI", sans-serif; }Adding a Cyrillic layer for Russian traffic
ExampleA Russian page contains U+0400–U+04FF codepoints, so the browser fetches brand-400.cyrillic.woff2 in addition to the Latin base. No Accept-Language sniffing required.
@font-face {
font-family: "Brand";
font-weight: 400;
font-display: swap;
src: url(/f/brand-400.cyrillic.woff2) format("woff2");
unicode-range: U+0400-04FF, U+0500-052F;
}
/* Generate with: font-subsetter, preset = cyrillic
(the Latin Filter only does U+0020-00FF) */Per-weight build manifest
ExampleA small manifest keeps the layered build organised. Each weight gets a Latin subset (Latin Filter) and, where needed, script subsets (font-subsetter presets).
brand-400.latin.woff2 <- Latin Filter -> ttf-to-woff2 brand-400.ext.woff2 <- font-subsetter latin-ext brand-400.cyrillic.woff2 <- font-subsetter cyrillic brand-700.latin.woff2 <- Latin Filter (Bold weight) brand-700.ext.woff2 <- font-subsetter latin-ext Rule of thumb: Latin base for every weight you ship; script layers only for weights used in that script's UI.
Fallback so missing scripts still read
ExampleIf you don't ship a CJK subset but a Japanese string slips into the CMS, the system fallback should render it rather than tofu. Pair the brand family with a system stack.
body {
font-family: "Brand",
-apple-system, BlinkMacSystemFont, "Segoe UI",
"Hiragino Sans", "Noto Sans CJK JP",
sans-serif;
}
/* Brand covers Latin (+layers); the OS fonts catch
any script you didn't subset. Generate the stack
with system-font-stack-generator. */Verifying the right subset fired
ExampleIn DevTools Network, filter for woff2 and load each locale. Only the matching subsets should download. If the Cyrillic file never loads on a Russian page, your unicode-range is wrong or the file 404s.
Russian page (DevTools Network, woff2 filter):
brand-400.latin.woff2 200 16 KB (page has ASCII
in nav/footer)
brand-400.cyrillic.woff2 200 14 KB ✓ fetched
brand-400.ext.woff2 -- (no Extended chars)
English page:
brand-400.latin.woff2 200 16 KB
(nothing else) ✓ minimal payloadEdge 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.
unicode-range gaps leave codepoints with no font
MisconfiguredIf your ranges don't cover a codepoint that appears on the page, that character falls through to the system fallback — not always a disaster, but a visual inconsistency. The classic gap: declaring U+0000-00FF for Latin but the page uses œ (U+0153), which no layer covers because you skipped the latin-ext file. Audit ranges against actual content per locale.
Overlapping ranges cause double downloads
WastefulIf two @font-face rules both claim a codepoint (e.g. Latin and Extended both list U+00C0–U+00FF), the browser may fetch both files for a page that uses that character. Keep ranges disjoint: Latin = U+0000-00FF, Extended = U+0100-024F, U+1E00-1EFF, with no overlap. The Latin Filter's fixed range stops at U+00FF, which makes the boundary clean.
Kerning loss visible on hero headlines
By designThe Latin Filter drops GPOS, so a large hero headline shows un-kerned AVA/To/Wa gaps. At body sizes it's invisible; at 64px display it isn't. For headline weights specifically, subset with a harfbuzz tool that preserves kerning (see the batch-script guide) and reserve the in-browser filter for body weights. Audit with kerning-pair-auditor.
CMS lets editors paste an unsupported script
Content riskA Latin-first site can receive a stray Japanese or Arabic string from an editor. If you didn't subset that script and your fallback chain lacks a covering OS font, it renders as tofu (□). Mitigate by ending the font-family with a broad system stack (via system-font-stack-generator) so the OS catches anything you didn't ship.
Old browsers ignore unicode-range and download everything
LegacyEvery browser since ~2016 honours unicode-range, but a very old engine downloads all @font-face files regardless. The fallback behaviour is correct (text still renders), just not bandwidth-optimal. Given current browser share this is negligible; don't complicate the build to chase it.
Variable font flattened to one weight by the filter
By designThe Latin Filter's opentype.js rebuild produces a static font at the default instance — the fvar/gvar axes are gone. For a layered deployment you want explicit static weights anyway, so freeze each weight with variable-font-freezer first, then Latin-filter each frozen instance into its own @font-face.
FOIT/flash because font-display wasn't set
UX regressionWithout font-display: swap (or optional), a subset that takes a moment to load can leave text invisible (FOIT). For an i18n site where a visitor may need to fetch a script layer mid-page, always set font-display: swap so the system fallback shows immediately. Tune the strategy with font-display-strategy.
Preloading the wrong subset
Misconfigured<link rel=preload as=font> forces an early download. Preload the Latin base (everyone needs it) — but do NOT preload the Cyrillic or Greek layers globally, or every Western visitor eagerly downloads a file they'll never use, defeating the whole strategy. Use preload-tag-builder for the base only.
Self-hosting cache headers missing
PerformanceThe whole point is that the small Latin file caches across every page. Serve subsets with long-lived immutable cache headers and content-hashed filenames. Without caching, the browser re-validates the font on every navigation, erasing the latency win of the small subset.
Subset font has no glyphs for a 'covered' language
Source limitationGenerating a cyrillic subset from a font that doesn't actually contain Cyrillic glyphs yields nothing useful — and the Latin Filter would throw 'Subset would be empty' if you somehow pointed it at a Cyrillic-only source. Confirm the typeface covers every script you plan to layer with character-coverage-map before building the deployment.
Frequently asked questions
How does unicode-range save bandwidth without server logic?
Each @font-face rule declares which codepoints it serves via unicode-range. The browser inspects the text it needs to render and downloads only the font files whose ranges intersect that text. So a Russian page fetches your Cyrillic subset, an English page fetches only the Latin subset — all decided client-side, no Accept-Language branching on your server.
Can I use one font-family name across all the layers?
Yes — that's the design. Give every @font-face rule the same font-family (e.g. 'Brand') with different src and unicode-range. CSS treats them as one logical family; the browser composites the right glyphs from whichever subset covers each character. Your body { font-family: 'Brand', ... } stays a single declaration.
Why not just serve the whole multi-script font?
Because 80–95% of your traffic renders only Latin, and a monolithic multi-script font can be hundreds of KB to several MB. Latin-first ships ~16 KB to the majority and layers the rest on demand. The full font also still drops kerning when run through the in-browser filter, so you don't even gain layout fidelity by shipping it whole — use a harfbuzz subsetter if layout matters.
Does the Latin Filter cover all my Western European languages?
It covers German, Spanish, Italian, Portuguese, and the Nordic languages fully, and French except œ/Œ. It does NOT cover Polish, Czech, Turkish, Romanian, or Vietnamese — those need the latin-ext (and for Vietnamese, the vietnamese) layer from font-subsetter. Plan a latin-ext layer if any of those are in your traffic.
What output format does the filter give me, and is it web-ready?
It outputs uncompressed TTF — not web-ready as-is. Run every subset through ttf-to-woff2 before serving; WOFF2 is ~30% smaller and is what modern browsers expect. For legacy WOFF support, also generate WOFF with ttf-to-woff, though it's rarely needed now.
How do I handle kerning loss in a deployment?
The in-browser filter drops GPOS, so kerning is gone. For body text at typical sizes this is invisible. For display/headline weights where it shows, subset those specific files with a harfbuzz-based tool (pyftsubset / hb-subset) that preserves layout tables — the batch-script guide has the command. Many teams use the in-browser filter for body and harfbuzz for headlines.
Which subset should I preload?
Only the Latin base, because every visitor needs it. Preloading script layers (Cyrillic, Greek) globally forces every Western visitor to download files they'll never use. Build the preload tag for the base font with preload-tag-builder and leave the on-demand layers to unicode-range.
What happens if a page has a character no layer covers?
It falls through to the next font in your font-family stack. End the stack with a broad system font set (from system-font-stack-generator) so unexpected scripts — a pasted CJK string, an emoji — render in a sensible OS font instead of tofu. The brand font handles Latin; the OS handles the long tail.
Is this approach GDPR-friendly?
Yes — generate all subsets in-browser and self-host them on your own origin. No request ever reaches a third-party font CDN, so no visitor IP is shared and no cookie-consent gate is needed for fonts. The Latin Filter and font-subsetter both process locally; nothing about your font or your visitors leaves your control.
Can I automate the whole layered build in CI?
Yes. The Latin base layers script cleanly with Node + opentype.js (browser-parity output) per the batch-script guide; the script subsets use the same pattern or pyftsubset for layout preservation. Programmatically, GET /api/v1/tools/latin-filter returns the option-free schema and the @jadapps/runner executes jobs locally so font bytes never leave your machine.
How do I verify the strategy is actually working?
Open DevTools → Network, filter for woff2, and load one page per locale. A clean Latin-first deployment downloads only the Latin file on English pages and Latin + the matching script subset on others. If a Western page pulls a Cyrillic file, your ranges overlap; if a Russian page shows boxes, your Cyrillic layer is missing or 404ing.
Does freezing a variable font change this plan?
If your brand font is variable, freeze each weight you ship to a static instance with variable-font-freezer before Latin-filtering — the filter flattens variable fonts to their default instance anyway. A layered static deployment is more predictable than shipping a variable font plus subsets, and avoids surprising weight collapses.
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.