How unicode-range reduces font payload size

This guide is part of the Unicode-Range & Subset Loading section, which sits under the broader Font Loading & Delivery Strategies blueprint. 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. 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.

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 pyftsubset or glyphhanger. The CSS descriptor alone does not strip glyphs from a file.
  • Each subset is served with Content-Type: font/woff2 and Cache-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.

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.

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.

Verification

Confirm the split actually trims bytes rather than just relabelling them:

  1. 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.
  2. 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.
  3. Use the Coverage tab to confirm glyph utilization is high; a near-fully-used subset means the range is cut tightly.
  4. In Application → Cache Storage, verify each subset URL has its own entry and the Cache-Control header reads immutable.
  5. 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.
Transfer size before and after unicode-range subsetting A bar comparison showing a full font family transfer shrinking to a small Latin subset after unicode-range splitting, roughly an eighty percent reduction. Full family ~120 KB Latin subset ~18 KB -85% transfer
Loading only the Latin subset cuts the transferred bytes by roughly 85% with no change to rendered output.

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

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-range to the full file does nothing to its size; the descriptor only chooses which file downloads. You must generate cut subsets with pyftsubset first.
  • Omitting font-display. Without it the rule defaults to auto, letting the browser block text during the subset fetch (FOIT). Set swap on every subset rule.
  • Missing crossorigin on the preloaded subset. The preloaded response is discarded and the font is fetched a second time — verify a single Network row per subset URL.

FAQ

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.

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 the full subsetting pipeline below.

Related