TYPEFOUND

Sign in

Back to the home page

This is for the people who run the site.
The typefaces are on sale to everyone.

← The journal

Chinese web fonts are ten megabytes

A Traditional Chinese web font is 11 MB where a Latin one is 92 KB. Here is where the bytes go, which techniques actually remove them, and why subsetting breaks on names.

2 September 2026 · 10 min read · Perhapxin

Download the variable TTF of Noto Sans TC from Google Fonts and you get 11,941,968 bytes. Fetch every file Google Fonts serves for Inter — all seven slices, Latin, Greek, Cyrillic, Vietnamese — and you get 94,620 bytes. Same outline technology, same Brotli compression, a factor of 126.

Every web performance guide written in English assumes the second number. "Keep your web fonts under 100 KB" is sound advice for a Latin site and meaningless for a Traditional Chinese one, where 100 KB buys about five slices of a sliced font and nothing at all of an unsliced one.

The techniques that fix this are well supported and none of them are new. They are simply never written down together, in order, with the Traditional Chinese specifics attached. Every byte figure below was measured by fetching the file on 16 September 2026 rather than recalled.

The character count is the entire story

A Latin text face with accents, punctuation and figures is a few hundred glyphs. The Chinese equivalents are not close.

Big5, the encoding a great deal of Taiwanese data still sits in, carries two levels of hanzi: 4,808 characters from the 常用國字標準字體表 and 6,343 from the less-common table, for 11,151 in total. The mainland's GB 18030-2022 sets a mandatory implementation subset of 27,533 characters. Unicode's main CJK Unified Ideographs block, U+4E00 to U+9FFF, holds 20,992, and across every extension block Unicode 17.0 defines 101,996 CJK unified ideographs.

The number that matters for a website sits between the first and the last. Parsing the unicode-range declarations Google Fonts serves for Noto Sans TC gives 16,254 code points, of which 12,761 are CJK ideographs. That is what "covers Traditional Chinese" means in production: roughly thirteen thousand characters.

11,151hanzi in Big5's two levels
12,761ideographs Google Fonts ships for Noto Sans TC
101,996CJK unified ideographs in Unicode 17.0
The first is the Taiwanese standard, the second is what a real web font carries, the third is the ceiling nobody ships.

It is worse than the ratio suggests, because a hanzi outline is not the size of a Latin one. Thirteen thousand glyphs, each with more strokes and more curve segments than an o, is why the file is measured in megabytes rather than hundreds of kilobytes.

What that costs as bytes

File What it is Size
NotoSansCJKtc-Regular.otf full Noto Sans CJK, TC build, one weight 15.7 MB
NotoSansTC[wght].ttf Google Fonts variable, seven weights 11.4 MB
NotoSansTC-VF.otf Noto subset variable, seven weights 9.7 MB
NotoSansTC-Regular.otf Noto subset OTF, one weight 5.4 MB
Noto Sans TC 400, sliced 105 WOFF2 files from the Google Fonts CSS 2.14 MB total
Inter 400, sliced 7 WOFF2 files, every script it covers 92.4 KB total

The smallest single-file option is 5.4 MB. There is no version of "just ship the font" that ends well.

unicode-range slicing is the technique that matters

Cut the font into many WOFF2 files and declare each one with its own @font-face and its own unicode-range. The browser parses all the declarations, works out which characters the page actually renders, and downloads only the files containing them. No JavaScript, no server logic, one request per slice touched.

This is what Google Fonts does, and the scale is worth seeing. Noto Sans TC at weight 400 is 105 separate @font-face blocks in the stylesheet, ranging from 5.0 KB to 36.5 KB each and averaging 20.9 KB. A page of Traditional Chinese prose touches a few hundred distinct characters, and because the slices are ordered by frequency those characters cluster in the early ones — our judgement is that a typical article pulls somewhere between five and fifteen slices, not 105.

/* One @font-face per slice. All of them share family, weight and style.
   Real ranges come out of a frequency list, so they are scattered rather
   than tidy blocks; these are shortened for illustration. */
@font-face {
	font-family: 'Site Han';
	src: url('/fonts/han.0.woff2') format('woff2');
	font-weight: 400;
	font-style: normal;
	font-display: swap;
	unicode-range: U+0020-007E, U+3001-3002, U+FF0C, U+FF1A,
	               U+4E00, U+4E0A, U+4E0B, U+4E2D, U+4EBA, U+5927;
}

@font-face {
	font-family: 'Site Han';
	src: url('/fonts/han.1.woff2') format('woff2');
	font-weight: 400;
	font-style: normal;
	font-display: swap;
	unicode-range: U+4E01, U+4E03, U+4E08, U+4E09-4E0D, U+4E11;
}

Two things people get wrong. Every slice must carry an identical font-family, font-weight and font-style — they are one face described across many files, and a mismatch on any of the three makes the browser treat them as separate faces. And do not preload the slices: preloading is a promise about what the page needs made before layout knows, which is exactly the information slicing exists to use.

cn-font-split will perform the split and emit the stylesheet for you. pyftsubset from fonttools will do it if you want to drive the ranges yourself.

Slicing is the only technique here that costs nothing in coverage. Every character in the original font still exists in some slice, so the page that needs 甯 gets the slice with 甯 in it. Everything below this line trades coverage for bytes.

Static subsetting, and when it is safe

Cut the font down to the characters the site actually sets, once, at build time.

pyftsubset NotoSansTC-Regular.otf \
  --text-file=site-copy.txt \
  --layout-features='*' \
  --flavor=woff2 \
  --output-file=han.subset.woff2

The effect is large. Our own largest face covers 12,201 characters; the subset this site serves covers 705, taking it from about 160 KB to 73 KB. Run the same arithmetic against a 5.4 MB Chinese font and the choice is between megabytes and tens of kilobytes.

It is safe when the copy is fixed and passes through your build: headings, navigation, marketing pages, a price list generated from the repository. Generate the character list in CI from the rendered output, never by hand, or the subset drifts away from the copy within a fortnight.

It is dangerous the moment any rendered text is not in the repository at build time. Names, comments, product titles typed by staff, addresses, a CMS field edited after deploy. A reasonable compromise is to static-subset the display face used for fixed headings and serve a sliced or system font for body copy and anything a person can type.

A missing glyph in Chinese is a broken page

English-language performance advice skips this because Latin has no equivalent failure mode.

If a Latin subset drops ñ, the browser finds ñ further down the font stack and one character renders in a slightly different face. It looks careless and it still reads. If a Chinese subset drops 堃, the same fallback fires, but the replacement face has a different stroke weight, a different character width and often a different regional style — so one character in the middle of somebody's name arrives visibly wrong. If no installed font has the character at all, the browser draws a box.

Taiwanese given names are precisely where the rare characters live. 堃, 喆, 甯 and 犇 are all real name characters, all well outside the high-frequency range, and the long tail runs past U+20000 into Unicode's extension blocks, beyond Big5 entirely.

Never static-subset a font that will render a person's name. A frequency-based subset is a bet that none of your customers has an unusual character in theirs, and in Taiwan that bet loses on a regular schedule. Slicing and system fonts both avoid the problem completely; static subsetting is where it happens.

Dynamic subsetting

A service sits in front of the font, inspects the characters a page uses, and builds a file per request. The byte count is the best available — you download what you set and nothing else — and there is no coverage gap, because the service holds the whole font.

The costs are real. The font cannot begin downloading until something has told the service which characters to include, which puts a round trip in front of the request. A third party sees the text of your pages. And the dependency fails closed: if the service is down, the font is not late, it is absent. For most sites, slicing gets close enough without any of that.

Variable weights help, but less than you would think

The measured figures: NotoSansTC-VF.otf carries seven weights in 9.7 MB, while NotoSansTC-Regular.otf carries one in 5.4 MB. The variable file is 1.8 times the size of a single static weight and does the work of seven, so it wins outright from about three weights upwards.

If you need only one or two, instance it down and ship static weights instead:

fonttools varLib.instancer "NotoSansTC[wght].ttf" wght=400 \
  --output NotoSansTC-400.ttf

Our judgement is that two weights is the right budget for a Chinese site — a regular and one heavier — and that every additional weight should have to justify several megabytes before slicing.

The system stack is a legitimate answer

Every platform already ships a properly drawn Chinese face, locally, at zero bytes. For body text this is less a compromise than the obvious default.

body {
	font-family: "Your Latin Face", system-ui, -apple-system,
	             "PingFang TC", "PingFang HK",
	             "Microsoft JhengHei UI", "Microsoft JhengHei",
	             "Noto Sans CJK TC", "Noto Sans TC",
	             "Source Han Sans TC", "Heiti TC",
	             sans-serif;
}

Name the Latin face first. font-family falls through per character, not per element, so putting your Latin face at the head gives Latin text your face and hands every hanzi to the first Chinese entry that has it.

Platform Face that answers Shipping since
macOS, iOS, iPadOS PingFang TC OS X 10.11 and iOS 9, 2015
Older macOS and iOS Heiti TC Mac OS X 10.5, iOS 4
Windows 8 and later Microsoft JhengHei UI Windows 8
Windows Vista and 7 Microsoft JhengHei Windows Vista
Android Noto Sans CJK TC shipped with the system
Linux Noto Sans CJK TC or Source Han Sans TC distribution-dependent

What you give up is control: your Chinese body text looks different on macOS than it does on Windows. At 16px, in a paragraph, that difference costs a reader far less than eleven megabytes does.

font-display when the font is this big

swap is the sane default for sliced Chinese. The page renders in the system face immediately and upgrades slice by slice as the files land. block on a multi-megabyte font means up to three seconds of invisible text, which is the worst outcome available.

optional is worth more in Chinese than it is in Latin. It tells the browser to use your font only if it is already cached, so a first visit renders instantly in the system face and the download lands in time for the second. On a 2 MB sliced font over a Taiwanese mobile connection, that is frequently the better trade.

The swap reflow is also worse here, because fallback CJK metrics differ more than Latin ones do. The fix is the same: declare a local() fallback @font-face and correct it with size-adjust, ascent-override and descent-override until the two faces occupy the same space.

What we ship, and what we do not

We make eight display typefaces. Most cover 105 characters. Quill covers 634. Cosmic, our largest, covers 12,201 — Latin, Greek, Cyrillic, Korean syllables, Japanese kana and Thai.

None of them covers Chinese, and that is deliberate. A display face that covers most of Chinese looks finished right up to the moment somebody sets their own name in it. We would rather ship nothing for Chinese than ship a face that fails on 堃.

More from the journal

These notes are written while drawing the typefaces this site sells. They are display faces, licensed for commercial work, from $3.