- date
- entry
- 018
- topic
- performance
- rev
- —
Do not webfont your Chinese: one stylesheet cost 800ms
This site used to pull its fonts from Google Fonts. Measured in Lighthouse, that one stylesheet accounted for close to 800 milliseconds of first paint — on a static site whose entire payload is a few dozen kilobytes.
The original line was the one from the official docs:
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=..." />
The problem is not its size — that CSS is a couple of kilobytes. The problem is that it is render-blocking: the browser must fetch every stylesheet and build the CSSOM before it dares paint a single pixel. Until it arrives, the screen is blank.
Where the 800 milliseconds goes
It is a third-party origin, so "download a few kilobytes" is preceded by a whole preamble:
- DNS lookup for
fonts.googleapis.com - TCP connection plus TLS handshake
- Fetch the CSS
- Parse it, discover font files on
fonts.gstatic.com— a different origin, so repeat 1–3
preconnect removes the waiting in steps 1 and 2, but it can only warm known origins; it
cannot remove the sequential dependency of "you learn which font files to fetch only after the CSS
arrives". On mobile networks that chain is several hundred milliseconds.
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
The crossorigin on the second line is not optional. Fonts are fetched in anonymous CORS
mode, and without that attribute the connection preconnect opened is not the one the fetch
uses — you have warmed a connection nobody wants and still pay for a second handshake.
Getting it out of the paint path
The first change makes the load non-blocking:
<link rel="preload" as="style"
href="https://fonts.googleapis.com/css2?family=..."
onload="this.onload=null;this.rel='stylesheet'" />
<noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=..." />
</noscript>
rel="preload" downloads without applying, so it blocks nothing; the onload
then flips rel to stylesheet and the browser applies it.
this.onload=null stops some browsers re-firing the handler after the rel change.
The <noscript> copy covers people with JavaScript disabled — note that the trick
relies on the onload attribute running, so this is not a pure-CSS solution.
The cost is FOUT: the page paints in a fallback face and reflows once the webfont lands.
display=swap is the explicit choice of that behaviour — better to read text that changes
appearance than to stare at nothing.
The real fix is not loading Chinese at all
Everything above optimises how to load. For a Chinese site the deeper question is whether to.
A Latin face covers a few hundred glyphs and subsets down to tens of kilobytes. A complete Traditional Chinese face covers tens of thousands of characters and is measured in megabytes. That is three orders of magnitude — not a gap compression or tuning closes, but a difference of scale.
Modern CJK webfont services slice the face into hundreds of unicode-range shards and
fetch only the ones a page uses. That genuinely works, and it also means:
- as many requests as there are shards covering your above-the-fold text
- the reader scrolls, meets new characters, and more requests fire
- every shard is discovered only after the CSS is parsed
And in return for what? Chinese readers already have a decent sans face installed. Downloading megabytes so text matches the mockup is a poor trade on a site that is mostly text.
The system stack
So Chinese goes to the system, and only Latin and monospace keep a webfont:
--sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "PingFang TC",
"Noto Sans TC", "Microsoft JhengHei", "Helvetica Neue", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
The order matters — the browser walks the list per character, taking the first face that has that glyph:
- IBM Plex Sans goes first and contains only Latin glyphs, so Chinese characters fall through to later entries automatically. You get mixed typesetting — designed face for Latin, system face for Chinese — for free
- PingFang TC on Apple systems, Noto Sans TC on Android and much of Linux, Microsoft JhengHei on Windows
-apple-system/BlinkMacSystemFontput Latin on the system UI face when the webfont has not arrived, which reads closer to native than Helvetica
The trade you accept
Honestly: the site does not look identical across operating systems.
PingFang and JhengHei differ in weight, width and punctuation placement, so the same paragraph can occupy a different number of lines on macOS and Windows. If your design depends on exact line counts and break points, this approach will bother you.
I accept it, because the content here is long-form text and readers spend far more time reading than looking at the typesetting — and system faces are tuned for sustained reading on their own platforms.
If a specific Chinese face is genuinely required — brand identity, display headings — the compromise is to use it for headings only and subset it yourself: pull out the few dozen characters those headings actually use and the file returns to tens of kilobytes. Body text stays on the system stack.
Confirming it worked
Not by feel. Lighthouse's "eliminate render-blocking resources" audit names the resource and how long it blocked; run it before and after and you have numbers.
What matters is running both under the same conditions — same throttling profile, private window, extensions off. Same principle as verifying a deploy by content: have a repeatable measurement before claiming an improvement.
If you remember one thing
On a Chinese site, fonts are not a performance problem, they are a scale problem. No amount of tuning the loading strategy changes the fact that the face contains tens of thousands of characters. Ask whether to load it before asking how.
Revision history
- First published