PageSpeed Insights ran, and the mobile score was 55. FCP, First Contentful Paint, was 17.2 seconds. Users had to wait 17 seconds before seeing the first screen.
The root cause was fonts: not images, not JavaScript.
If you build sites for CJK users, whether Traditional Chinese, Japanese, or Korean, this problem matters even if the rest of your stack is globally optimized. CJK fonts are heavy enough to dominate the first paint on their own.
Conclusion First
| Item | Before optimization | After optimization |
|---|---|---|
| fonts.css | 1,757 lines (161KB) | 39 lines (~2KB) |
| @font-face declarations | 216 | 3 |
| Font files | 213 woff2 files | 3 woff2 files |
| CJK body font | Noto Sans TC (web font) | System font |
| CJK heading font | Noto Serif TC (web font) | System serif |
The move was blunt: remove all CJK web fonts and switch to system fonts. Keep only Crimson Pro for Latin headings.
Sounds like a downgrade? Zhihu, Medium, and Matters all do this.
Why CJK Web Fonts Are So Heavy
A Latin font is usually around 20-50KB in woff2 because it only needs a few dozen letters.
CJK is different. Common Traditional Chinese alone has thousands of characters. A complete Pan-CJK font such as Noto Sans CJK covers more than sixty thousand characters, and one raw weight can reach 15-40MB.
Google Fonts solves this by splitting the large file into hundreds of small chunks and using CSS unicode-range so the browser only downloads characters that appear on the page. The mechanism is smart: Google uses machine learning to analyze which characters often appear together and packs them into the same chunk. A normal page triggers only 5-15 subset downloads, roughly 250KB-1MB.
But the problem sits elsewhere.
The Structural Bottleneck: 161KB of CSS, Not the Font File
fonts.css had 1,757 lines. There were 216 @font-face declarations alone. The CSS file itself was 161KB.
The browser does not start rendering anything until it finishes parsing that CSS. On a mobile 4G network, downloading and parsing this CSS alone can eat several seconds before any font file even starts downloading.
Three font families together:
- Noto Sans TC: 107 unicode-range subsets for body text
- Noto Serif TC: 110 subsets for CJK headings
- Crimson Pro: 3 subsets for Latin headings
The first two both had more than a hundred @font-face rules. Even if the browser downloaded only a small subset of them, the huge CSS manifest was already enough to slow first paint.
How Large Sites Handle CJK Fonts
Before changing anything, I ran a round of Deep Research and asked four AI models the same question. The conclusion was surprisingly consistent:
High-traffic CJK sites almost always use system fonts for body text.
- Zhihu: PingFang SC → Microsoft YaHei → system sans-serif. Zero web fonts
- Medium: Latin body text uses Charter, while CJK falls back to system fonts
- Matters.news: Mostly system fonts, with a small number of custom fonts
Since 2022, Chrome, Safari, and Firefox have implemented cache partitioning: a Google Fonts file downloaded on site A is not visible to site B. The old advantage of “everyone sharing the CDN cache” is gone.
Once cross-site caching disappears, self-hosted CJK web fonts become pure performance cost unless there is a very strong brand reason.
font-display: optional vs swap
Font loading behavior has an easy-to-misunderstand choice.
font-display: swap is the default recommendation in many tutorials: show the system font first, then “swap in” the web font after download. The problem is that CJK web fonts and system fonts often have different character widths. When the swap happens, the whole layout jumps. Google calls this CLS, Cumulative Layout Shift, and it directly hurts Core Web Vitals.
font-display: optional is different. It gives the font a 100ms window: if the font downloads within those 100ms, or is already cached, use it. If not, keep using the system font forever and never jump.
For CJK body text, optional plus a system-font fallback is the safest choice. The layout does not jump, and users usually will not notice the font difference.
The retained Crimson Pro, used for Latin headings, uses optional plus preload. The file is only a few dozen KB, so in most cases it loads inside the 100ms window.
What Actually Changed
Four files, not much code.
1. Font Stack
/* body: system sans-serif for CJK */
--font-sans: 'PingFang TC', 'Noto Sans CJK TC', 'Microsoft JhengHei',
system-ui, -apple-system, sans-serif;
/* headings: Crimson Pro (Latin) + system serif (CJK) */
--font-serif: 'Crimson Pro', 'Songti TC', 'PMingLiU', Georgia, serif;
macOS / iOS gets PingFang TC, Windows gets Microsoft JhengHei, and Android gets Noto Sans CJK TC. All three are among the best Traditional Chinese sans-serif fonts on their platforms, and they require zero download because they are already on the user’s device.
2. Slim Down fonts.css
Cut from 1,757 lines to 39 lines. Keep only the three Crimson Pro subsets, Latin, Latin-ext, and Vietnamese, and switch all of them to font-display: optional.
3. Add preload
Add one line at the very top of <head>:
<link rel="preload" href="/fonts/crimson-pro-latin.woff2"
as="font" type="font/woff2" crossorigin />
This lets the browser start downloading Crimson Pro before parsing CSS. Combined with the 100ms optional window, most users see Crimson Pro in headings.
4. Clean Up
Delete 213 noto-*.woff2 files. public/fonts/ goes from more than two hundred files down to three.
Tradeoffs
This approach is not perfect.
The CJK part of Windows headings falls back to PMingLiU, which does not look great. Songti TC on macOS looks much better. If that difference is unacceptable, consider making headings sans-serif too, or using a tool like cn-font-split to subset only a few hundred common characters.
Brand consistency drops slightly. Fonts are not identical across devices. But most readers will not notice small font differences. They will notice a page taking 17 seconds to appear.
2026 CJK Font Checklist
- Prioritize system fonts for CJK body text. PingFang TC / Microsoft JhengHei / Noto Sans CJK TC are already good enough
- Use custom fonts only for brand elements: headings, logos, special UI, and aggressively subset them
- Self-host beats CDN. Cache partitioning killed the cross-site cache advantage of Google Fonts
- Use
font-display: optionalfor CJK body text. Useswaponly for brand elements where a custom font is truly required - Preload critical fonts, but only one or two files that are genuinely needed
- Watch the size of fonts.css. Hundreds of
@font-facerules are themselves a performance bottleneck, even if the browser downloads only a few subsets - Tool recommendation: if you really need custom CJK fonts,
cn-font-split(Rust/WASM) can do Google Fonts-level smart splitting and supports Vite / Webpack integration
Further Reading
Penchan’s Take
penchan.co is self-hosted, and I really did step on the CJK font problem. At first, I casually used Google Fonts, then found that the font files were huge and first paint was slow. Later, I moved to system fonts plus self-hosted subsets and pulled LCP back into a reasonable range. For Chinese sites, defaulting to PingFang / Noto Sans CJK is far more practical than chasing a custom font everywhere.
FAQ
Q: Why not use the Google Fonts CDN?
Since 2022, major browsers have implemented cache partitioning, so the cross-site cache advantage is gone. Self-hosting plus system fonts is now usually the fastest approach.
Q: Will system fonts look different across devices?
Yes, but not by much. macOS / iOS use PingFang, Windows uses Microsoft JhengHei, and Android uses Noto Sans CJK. All three are high-quality sans-serif CJK fonts, and the reading experience is very close.
Q: What is the difference between font-display: optional and swap?
swap shows a system font first, then switches to the web font later, causing layout shift (CLS). optional gives the font 100ms to load; if it misses that window, the page keeps using the system font and does not jump.
— Penchan