How unicode-range reduces font payload size
This guide is part of the Unicode-Range & Font Subset Loading guide, which sits under the broader Font Loading & Delivery Strategies area. It resolves one narrow inefficiency: a default @font-face ships every glyph in the family — Latin, Cyrillic, Greek, symbols, ligatures — even when the page renders only basic English. The unicode-range descriptor lets the browser fetch just the subsets a page actually uses, cutting font transfer by 60–90% with zero change to rendered output.
Problem Statement
A single @font-face rule maps a font file to a family name. When that page paints any character covered by the file, the browser downloads the whole file — including the vector outlines for thousands of glyphs the page will never render. A full multilingual .woff2 is commonly 100–150KB; an English-only page uses well under 15% of it. Multiply that waste across every weight in a family — regular, medium, bold, italic — and a typical four-weight system can ship 400–600KB of font bytes for a page that renders perhaps 300 distinct characters. The unicode-range descriptor changes the contract: you declare several @font-face rules that share one family name, each pointing at a per-language subset file and each tagged with the codepoint range it covers. The browser parses all the rules but downloads only the file whose range intersects the characters on the page. The result is the same typography at a fraction of the bytes — provided the ranges do not overlap and each subset is genuinely cut, not just labelled.
It helps to be precise about what unicode-range actually is: a lazy-loading mechanism scoped to the CSS cascade, not a compression technique. The browser's HTML/CSS parser builds a table of every @font-face rule for a family before it ever paints text, computes the set of codepoints the current document needs, and only then issues network requests for the files whose ranges match. Nothing is downloaded speculatively. This is why the technique composes so well with everything else in the font-loading toolchain — format selection, preloading, and font-display — each one narrows a different dimension of the same request.
Prerequisites
Before splitting a family, make sure the inputs are in place — unicode-range controls which file is fetched, not how big it is:
- Per-range subset WOFF2 files already exist, cut with
pyftsubsetorglyphhanger. The CSS descriptor alone does not strip glyphs from a file. - Each subset is served with
Content-Type: font/woff2andCache-Control: public, max-age=31536000, immutable, using content-hashed filenames so the long cache is safe. - You have an accurate inventory of the codepoints your content uses. Audit real pages, not assumptions — a single curly quote (
U+2019) or euro sign (U+20AC) outside your range triggers a fallback glyph. - A metric-matched fallback stack is configured via fallback font metric matching, so any character outside your defined ranges renders without a layout shift.
- If the site is multilingual, decide up front whether non-Latin scripts get their own subset files (recommended) or ride along in one oversized file — mixing strategies mid-project makes the range table hard to audit later.
Implementation: Splitting a Family by unicode-range
Declare one @font-face per subset, all sharing the family name, each carrying its own src and unicode-range. The browser resolves the family once and fetches only the matching files.
Latin and Cyrillic subsets under one family name
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-cyrillic.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
body { font-family: 'Inter', system-ui, sans-serif; }
The two blocks share font-family: 'Inter', so the cascade treats them as one face split across files. The unicode-range descriptor on each is the deciding line: the browser computes the set of codepoints the page renders, intersects it with each rule's range, and fetches only the files whose ranges are hit. An English-only page downloads inter-latin.woff2 (~18KB) and never touches the Cyrillic file (~30KB), even though both rules are present. The Latin range above is deliberately precise — basic Latin plus the punctuation, currency, and arrow glyphs real content uses — because any character outside every declared range falls back to a system font for that glyph alone. font-display: swap on each subset keeps text visible during the fetch rather than blocking on it. Crucially, these files must already be subset on disk: pyftsubset inter.woff2 --unicodes="U+0000-00FF,..." --flavor=woff2 --output-file=inter-latin.woff2 is what actually shrinks the bytes.
Worked Example: A Multilingual Product Page
The two-range example above is the simplest case. A real product page — say, an e-commerce storefront serving English, French, and Vietnamese — needs a third tier because Vietnamese stacks combining diacritics onto Latin base letters (U+1EA0-1EF9, plus combining marks in U+0300-036F) sit outside both the basic-Latin and Latin-Extended-A ranges most default subsets cover. Ship it as a third file:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-vietnamese.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
A French visitor never triggers the Vietnamese file because accented French characters (é, à, ç) live inside U+0000-00FF and U+0152-0153, already covered by the Latin range. A Vietnamese visitor triggers both files, because Vietnamese text mixes plain Latin letters with the precomposed diacritic block — that's expected and still far cheaper than shipping one file covering every Latin-script language pre-emptively. This is also the pattern Google Fonts itself uses: the CSS @import or <link> for a family like Roboto returns dozens of @font-face blocks, each scoped with its own unicode-range, and the browser silently discards the ones it doesn't need — you can see this today by inspecting the response of fonts.googleapis.com/css2?family=Roboto in DevTools.
Preload and Inline Variant
For the critical Latin subset, fetch it before CSS parsing and register the face inline so there is no render-blocking stylesheet on the critical path. Defer the rest.
Preloaded critical subset with inline registration
<link rel="preload" href="/fonts/inter-latin.woff2" as="font"
type="font/woff2" crossorigin fetchpriority="high">
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-latin.woff2') format('woff2');
font-weight: 400;
font-display: swap;
unicode-range: U+0000-00FF;
}
</style>
<!-- Non-Latin subsets live in the deferred stylesheet -->
<link rel="stylesheet" href="/css/fonts-extended.css"
media="print" onload="this.media='all'">
The preload hint starts the Latin subset fetch during HTML parse, and the inline @font-face registers the family without a blocking external stylesheet. crossorigin is required even same-origin, or the preloaded bytes are discarded and the font is fetched twice. Only the Latin range is preloaded — non-Latin subsets are declared in fonts-extended.css, loaded with the media="print" swap so they never block first render. A reader who never types a Cyrillic character pays nothing for it; a reader who does gets it on the second, deferred pass without a layout shift, because the metric-matched fallback held the space.
Note the trade-off this variant makes explicit: preloading is scoped to exactly one file. If your critical above-the-fold text spans two ranges (say, Latin body copy plus a Cyrillic brand name in the header), preload both subset URLs individually rather than preloading the whole family — a <link rel="preload"> per subset costs one extra request tag but avoids fetching ranges the initial paint doesn't need.
Edge Case: Range Count vs. Request Count
Splitting aggressively is not free. Every additional @font-face rule with a distinct unicode-range is a potential extra HTTP request, and each request carries its own TCP/TLS overhead unless HTTP/2 or HTTP/3 multiplexing amortizes it (see font CDN and edge delivery for how connection reuse changes this math). Over HTTP/2 on a warm connection, five 12KB subset requests parallelize cheaply and still beat one 120KB download in most conditions. Over HTTP/1.1, or on a cold TLS handshake with high RTT, going past four or five ranges can erase the byte savings in added round trips. A practical ceiling: split by script (Latin, Latin-ext, Cyrillic, Greek, CJK) rather than by individual language, and never split a single script into more than two or three ranges just to shave a few kilobytes further — the request overhead usually outweighs the marginal savings once files drop under roughly 8–10KB each.
A second edge case: icon fonts and symbol ranges. If a family embeds a private-use-area icon set (U+E000-F8FF) alongside text glyphs, give the icon range its own @font-face block and its own font-display value — icons are rarely LCP-critical, so font-display: optional on that range avoids reserving a swap period for glyphs the user may not scroll to. Mixing icon and text glyphs into one undifferentiated range forces every page to pay for both.
Verification
Confirm the split actually trims bytes rather than just relabelling them:
- Open DevTools → Network → filter Font, hard-reload with cache disabled. For an English-only page you should see only
inter-latin.woff2— the Cyrillic and Greek files must be absent. - Compare the Transferred column against the Resource (decompressed) size, and against the full unsplit family — the Latin subset should be roughly 15–20% of the full file.
- Use the Coverage tab to confirm glyph utilization is high; a near-fully-used subset means the range is cut tightly.
- In Application → Cache Storage, verify each subset URL has its own entry and the
Cache-Controlheader readsimmutable. - Force a non-Latin character onto the page and confirm the matching subset — and only that subset — now appears in the waterfall, proving the ranges route correctly.
- In production, corroborate the lab numbers with field data: the Resource Timing API reports
transferSizeper font URL for real visitors, so you can confirm the Cyrillic file genuinely stays at zero requests for your English-majority traffic segment rather than just in your own test session.
The reduction in the diagram only holds when each subset is a separate file and the ranges do not overlap. The table below shows how a typical multilingual family breaks down once split.
| Subset | unicode-range (abbrev.) | Approx. WOFF2 size | Fetched for English page |
|---|---|---|---|
| Latin | U+0000-00FF + punctuation | ~18 KB | Yes |
| Latin-ext | U+0100-024F | ~12 KB | No |
| Cyrillic | U+0400-045F | ~30 KB | No |
| Greek | U+0370-03FF | ~14 KB | No |
| Full family (unsplit) | all | ~120 KB | — |
Interaction with Variable Fonts
unicode-range and variable fonts solve different problems and stack cleanly. A variable font already collapses several static weights into one file, but that file still ships every glyph for every registered axis position. Applying unicode-range to a variable @font-face still only chooses which file downloads based on script — it does nothing to the axis space. To actually shrink a variable font, you subset both dimensions: cut the glyph set with pyftsubset and, separately, restrict the axis range with fonttools varLib.instancer as covered in subsetting variable fonts by axis. A variable font that is both script-subset via unicode-range and axis-trimmed via instancer can land smaller than a single static weight of the same family, while still exposing the weight range your design system uses.
Common Pitfalls
- Overlapping ranges. If two subsets both claim a codepoint, the browser may download both files for that character and the cache fragments. Keep each codepoint in exactly one range.
- A character outside every range. Any glyph you forgot — a curly quote, an em dash, an accented name — falls back to a system font for that character, causing a mismatched mid-word render and possible CLS. Audit real content and include the punctuation block.
- Labelling without subsetting. Adding
unicode-rangeto the full file does nothing to its size; the descriptor only chooses which file downloads. You must generate cut subsets withpyftsubsetfirst. - Omitting
font-display. Without it the rule defaults toauto, letting the browser block text during the subset fetch (FOIT). Setswap(oroptionalfor non-critical ranges) on every subset rule. - Missing
crossoriginon the preloaded subset. The preloaded response is discarded and the font is fetched a second time — verify a single Network row per subset URL. - Over-splitting on a slow first connection. Ten narrow ranges each saving 3KB can cost more in request overhead than they save in bytes on a cold HTTP/1.1 connection. Group by script, not by micro-optimizing every individual saving.
Frequently Asked Questions
Does unicode-range work with variable fonts?
Yes. Apply unicode-range to a variable @font-face rule to control which subset file downloads. To also reduce the file's size, generate per-range subsets with pyftsubset --flavor=woff2 against the variable file — the CSS descriptor selects a file but does not strip glyphs from it, so an unsubset variable font is still fully transferred. Combine it with axis instancing for the largest reduction, as described above.
What happens to a character that falls outside every defined range?
The browser renders that single character in the next font in your stack. To keep it from shifting layout, match your fallback metrics with size-adjust and ascent-override, and use font-display values of optional on non-critical ranges to eliminate the swap shift entirely.
Do I have to hand-write the unicode-range strings?
No. glyphhanger scans your rendered pages and emits the exact ranges in use, and Google Fonts publishes battle-tested per-script ranges you can copy. Automating this in your build keeps the ranges in sync with content — see automating font subsetting in CI for wiring the scan-and-cut pipeline into a pull-request check.
How many subsets is too many for one family? There is no hard browser limit, but practically, keep it to one range per script the page actually serves — Latin, Latin-ext, Cyrillic, Greek, CJK, emoji — rather than one range per language. Splitting Latin further into "French Latin" and "German Latin" rarely pays off: the byte savings per split shrink quickly while the request count keeps climbing, and past four or five ranges the marginal gain is usually smaller than normal file-size variance between builds.