skip to content

In a CSS @font-face rule, what does the unicode-range descriptor do, and how does it change which font files the browser downloads?

level: middleimportance: should knowfreq 45%

answer

  1. a claim about which characters a file covers
  2. per-character font matching, not per element
  3. the fetch is skipped entirely, not deferred
  4. one family, several files, script boundaries
  5. a lie about coverage shows as fallback glyphs

basics

~20 s

The unicode-range descriptor declares which codepoints a font face covers. The browser downloads that face only if the page actually renders at least one character in the range, so a family split into per-script faces fetches only the scripts the page uses.

solid answer

~50 s

`unicode-range` turns one `font-family` into several `@font-face` rules, each pointing at a different subset file and each declaring the codepoints it covers. Font matching then works per character: for a character the browser needs, it picks the face in that family whose range contains it, and — crucially — a face whose range no rendered character falls into is **never fetched**. That is the whole win. A hosted font service splits a family into `latin`, `latin-ext`, `greek`, `cyrillic` and `vietnamese` slices exactly this way, so an English page downloads one small file instead of the whole multi-script family, while a page with a Cyrillic name in it silently pulls the extra slice. Two caveats: the declared range must honestly match the file's contents, because a character the browser routes to a face that lacks the glyph falls through to the next family in the stack; and splitting multiplies files, so each is a separate request and a separate cache entry.

code

css · 15 lines
css
@font-face {
  font-family: "Fam";
  font-weight: 400;
  src: url(/fonts/fam-latin.woff2) format("woff2");
  unicode-range: U+0000-00FF, U+2013-2014, U+2018-201D;
}

@font-face {
  font-family: "Fam";
  font-weight: 400;
  src: url(/fonts/fam-cyrillic.woff2) format("woff2");
  unicode-range: U+0400-045F, U+0490-0491;
}

body { font-family: "Fam", sans-serif; }

go deeper

for a junior

Know that unicode-range appears inside @font-face and lists the characters that file covers, and that several such rules can share one font-family name.

for a middle

Explain that font matching is per character and that a face whose range matches nothing on the page is never requested at all. Be ready to show two rules for one family and say which file an English page fetches.

for a senior

Discuss when splitting is worth it — whole scripts most visitors never render — and the failure mode where a declared range overstates the file's coverage and produces stray fallback characters in production text.

for a principal

Own the generation of these rules: ranges derived from the build's subset output, the rule count multiplying across weights and styles, and the policy on how finely to split given request overhead versus bytes saved across your real locale mix.

## The descriptor `unicode-range` is a descriptor inside a CSS `@font-face` rule, alongside `src`, `font-weight` and `font-style`. Its value is a list of codepoints and codepoint ranges written in the `U+` notation: single values (`U+0041`), ranges (`U+0000-00FF`), and wildcards (`U+04??`, meaning `U+0400-04FF`). It says: *this file covers these characters*. If you omit it, the default is `U+0-10FFFF` — the face claims everything, which is what a single unsplit font file should do. ## The download behaviour CSS font matching is per character. When the browser lays out a run of text, for each character it walks the families in the `font-family` list; within a family it selects among the declared faces by style, weight and stretch, and then checks whether the candidate face's `unicode-range` contains that character. A face that no rendered character maps into is not needed, and the browser does not fetch it. This makes `unicode-range` a **download filter**, not a rendering filter. It does not remove glyphs from a file and it does not delay a fetch until later; it decides whether the fetch happens at all, based on what the page renders. ```css @font-face { font-family: "Fam"; src: url(/fonts/fam-latin.woff2) format("woff2"); unicode-range: U+0000-00FF, U+2013-2014, U+2018-201D; } @font-face { font-family: "Fam"; src: url(/fonts/fam-cyrillic.woff2) format("woff2"); unicode-range: U+0400-045F, U+0490-0491; } ``` Both rules declare the same `font-family`, so `font-family: "Fam", sans-serif` covers both. An English page fetches only `fam-latin.woff2`. The moment a Cyrillic character appears in rendered text, the second file is requested. ## Why this beats one big file A multi-script family is dominated by scripts a given visitor never sees. Shipping one merged file makes every visitor pay for all of them, on the critical path, competing with content. Splitting by script and gating each split with `unicode-range` charges each visitor only for what their page renders. It is the delivery-side counterpart to subsetting: subsetting decides what exists in a file, `unicode-range` decides which files get pulled. ## The failure modes **Lying about the range.** The descriptor is a claim, not a measurement — nothing verifies it against the file. Declare `U+0000-00FF` on a file that actually lacks `ö` and the browser routes `ö` to that face, finds no glyph, and falls through to the next family in the stack. The result is one character in a different typeface mid-word, which is easy to miss in review. Generate ranges from the subset you actually built. **A stray character pulls a whole slice.** Because the check is per rendered character, a single Cyrillic or Greek character anywhere in the rendered page triggers that slice's download. On a page with user-generated content, one comment can add a request. That is usually the right behaviour — the alternative is a fallback glyph — but it means the byte count varies by content, which surprises people reading a lab measurement. **Fragmentation costs.** Each slice is its own request, its own connection slot in the worst case, and its own cache entry. Splitting a family five ways to save a few kilobytes each is churn; splitting it to avoid shipping an entire script is a real win. The rule of thumb is to split along script boundaries, not to micro-slice within one script. **Ranges must be written per weight and style too.** If you ship four weights and split each into three scripts, that is twelve `@font-face` rules. Generate them; do not hand-maintain them. ## Where it does not help If your product serves one script, `unicode-range` buys nothing over simply subsetting the file to that script — you would end up with one face whose range covers everything it contains. Its value appears the moment the family legitimately has to cover characters that most page views do not use.

  • If a page renders no character in a face's range, when does that file get downloaded?
    It does not. The face stays declared but unfetched for the life of the page. If a later DOM update introduces a character in that range, the browser requests the file at that point, which is why byte totals for a page with user-generated content vary between visits.
  • What goes wrong if unicode-range claims more codepoints than the file actually contains?
    The browser routes those characters to that face, finds no glyph, and falls through to the next family in the stack. You get individual characters rendered in a system font inside otherwise correct text. Always derive the declared range from the codepoint set you passed to the subsetter, so the two cannot drift apart.
  • Is there a downside to splitting a family into many unicode-range slices?
    Yes — each slice is a separate request and a separate cache entry, and the rule count multiplies by weight and style. Split along script boundaries where a whole script is often unused; micro-slicing within one script trades real request overhead for a saving that woff2 has largely already captured.

saying these in an interview costs you the question

  • Thinks unicode-range strips glyphs from the downloaded file
  • Believes it defers the download rather than avoiding it
  • Assumes the browser verifies the range against the file
  • Writes the range by hand instead of deriving it from the subset
  • Splits a single-script family into many slices for no gain

context