PageSpeed Insightsを走らせると、mobile scoreは55点、FCP(First Contentful Paint)は17.2秒でした。ユーザーは最初の画面を見るまで17秒待つ必要があります。

問題の根本はフォントでした。画像でもJavaScriptでもありません。

CJKユーザー向けのサイトを作るなら、繁体字中国語でも日本語でも韓国語でも、この問題は関係します。stackのほかの部分を最適化していても、CJKフォントだけでfirst paintを支配することがあります。

先に結論

項目最適化前最適化後
fonts.css1,757行(161KB)39行(~2KB)
@font-face宣言216個3個
フォントファイル数213個のwoff23個のwoff2
CJK body fontNoto Sans TC(web font)system font
CJK heading fontNoto Serif TC(web font)system serif

やったことはかなり強引です。CJK web fontをすべて削り、system fontへ切り替えました。残したのはLatin見出し用のCrimson Proだけです。

後退に聞こえますか?知乎、Medium、Mattersも同じ方向です。

CJK web fontはなぜ重いのか

Latin fontのwoff2はだいたい20-50KBです。必要な文字が数十個だからです。

CJKは違います。繁体字中国語だけでも常用字が数千あります。Noto Sans CJKのような完全なPan-CJK fontは6万文字以上を含み、単一weightの元ファイルが15-40MBになることもあります。

Google Fontsの解法は、大きなファイルを数百個の小さなchunkへ分割し、CSSのunicode-rangeでbrowserにページ上に出た文字だけdownloadさせることです。この仕組みは賢いです。Googleは機械学習で、どの文字が一緒に出やすいかを分析し、それらを同じchunkに入れます。一般的なpageなら5-15個ほどのsubset downloadで済み、だいたい250KB-1MBです。

ただし問題は別の場所にあります。

構造的なbottleneck:フォントファイルではなく161KBのCSS

fonts.cssは1,757行あり、@font-face宣言だけで216個ありました。このCSS file自体が161KBです。

browserはこのCSSをparseし終えるまで、何もrenderし始めません。mobileの4G networkでは、このCSSをdownload + parseするだけで数秒食うことがあります。フォントファイルのdownloadはその後です。

3種類のフォントを合わせるとこうなります。

  • Noto Sans TC:body用に107個のunicode-range subset
  • Noto Serif TC:CJK見出し用に110個のsubset
  • Crimson Pro:Latin見出し用に3個のsubset

前2つはどちらも@font-faceが100個を超えます。browserが実際にdownloadするのはその一部だとしても、巨大なCSS manifestだけでもfirst paintを遅くするには十分でした。

大規模サイトはCJKフォントをどう扱っているのか

変更前にDeep Researchを一通り回し、4つのAI modelに同じ質問をしました。結論は驚くほど一致しました。

trafficの大きいCJKサイトは、ほぼ本文にsystem fontを使っています。

  • 知乎:PingFang SC → Microsoft YaHei → system sans-serif。web fontはゼロ
  • Medium:Latin本文はCharter(custom font)、CJKはsystem fallback
  • Matters.news:system font中心、一部だけcustom font

2022年以降、Chrome、Safari、Firefoxはいずれもcache partitioningを実装しました。AサイトでdownloadしたGoogle Fontsのcacheは、Bサイトから見えません。かつての「みんなでCDN cacheを共有する」利点はなくなりました。

サイトをまたいだcacheがなくなると、CJK web fontをself-hostするcostは、強いbrand上の理由がない限り、純粋なperformance負担になります。

font-display: optional vs swap

フォント読み込みの挙動には、間違えやすい選択があります。

font-display: swapは多くのtutorialで標準的に勧められます。先にsystem fontを表示し、web fontのdownloadが終わったら「差し替える」方式です。問題は、CJK web fontとsystem fontでは文字幅がかなり違うことです。差し替えの瞬間、layout全体が少し跳ねます。GoogleはこれをCLS(Cumulative Layout Shift)と呼び、Core Web Vitalsのscoreに直接響きます。

font-display: optionalは違います。fontに100msの猶予を与えます。その100ms以内にdownloadできた、またはすでにcacheされていたならweb fontを使います。間に合わなければ、そのままずっとsystem fontを使い、layoutは跳ねません。

CJK本文では、optionalとsystem font fallbackの組み合わせが最も安全です。layoutは跳ねず、ユーザーもフォント差をほとんど気にしません。

残したCrimson Pro(Latin見出し用)は、optional + preloadにします。ファイルは数十KBだけなので、多くの場合100ms以内に読み込めます。

実際にどう変えたか

4つのファイルで、変更量は多くありません。

1. font stack

/* body:CJKはsystem sans-serif */
--font-sans: 'PingFang TC', 'Noto Sans CJK TC', 'Microsoft JhengHei',
             system-ui, -apple-system, sans-serif;

/* 見出し:Crimson Pro (Latin) + system serif (CJK) */
--font-serif: 'Crimson Pro', 'Songti TC', 'PMingLiU', Georgia, serif;

macOS/iOSはPingFang TC、WindowsはMicrosoft JhengHei、AndroidはNoto Sans CJK TCになります。3つとも各platformで品質の高い繁体字中国語sans-serifで、ユーザー端末に最初からあるためdownloadはゼロです。

2. fonts.cssを軽くする

1,757行から39行へ削りました。Crimson Proの3つのsubset(Latin、Latin-ext、Vietnamese)だけを残し、すべてfont-display: optionalにしました。

3. preloadを追加

<head>の最初に1行追加します。

<link rel="preload" href="/fonts/crimson-pro-latin.woff2"
      as="font" type="font/woff2" crossorigin />

これにより、browserはCSS parse前にCrimson Proのdownloadを開始できます。optionalの100ms windowと組み合わせると、多くのユーザーにCrimson Proの見出しが表示されます。

4. cleanup

213個のnoto-*.woff2ファイルを削除しました。public/fonts/は200個超のファイルから3個になりました。

tradeoff

このやり方は完璧ではありません。

Windows見出しのCJK部分はPMingLiUにfallbackし、見た目はあまりよくありません。macOSのSongti TCはかなりきれいです。この差を許容できないなら、見出しもsans-serifへ寄せるか、cn-font-splitのようなtoolで数百個の常用字だけsubset化する方法があります。

brandの一貫性は少し下がります。端末ごとに見えるフォントが完全には一致しません。ただ、多くの読者は小さなフォント差には気づきません。17秒待たされることには気づきます。

2026年版 CJKフォントchecklist

  1. CJK本文はsystem font優先。PingFang TC / Microsoft JhengHei / Noto Sans CJK TCの品質はすでに十分です
  2. custom fontはbrand elementだけに使う。見出し、logo、特殊UIなどに絞り、積極的にsubset化する
  3. self-hostはCDNより有利になりやすい。cache partitioningによりGoogle Fontsのサイト横断cache利点はなくなりました
  4. CJK本文にはfont-display: optionalswapは、本当にcustom fontが必要なbrand elementだけに使う
  5. 重要fontはpreloadする。ただし本当に必要な1-2ファイルだけ
  6. fonts.cssの大きさを見る。数百個の@font-face宣言は、それ自体がperformance bottleneckです。browserが実際にdownloadするsubsetが少なくても関係します
  7. tool推薦:本当にcustom CJK fontが必要なら、cn-font-split(Rust/WASM)でGoogle Fonts級の賢い分割ができます。Vite/Webpack統合にも対応します

関連記事


こぺんぎんの体験談

penchan.coはself-hostedなサイトで、CJKフォント処理は本当に踏みました。最初は何気なくGoogle Fontsを入れましたが、font fileが大きく、first paintが遅いことに気づきました。その後、system font + self-hosted subsetへ変えてLCPを戻しました。中国語サイトでは、すべてをcustom fontでそろえようとするより、PingFang / Noto Sans CJKを標準にするほうがはるかに実用的です。

よくある質問

Q: なぜGoogle Fonts CDNを使わないのですか?

2022年以降、主要browserはcache partitioningを実装し、サイトをまたいだcacheの利点は消えました。現在はself-host + system fontがperformance面で有利になりやすいです。

Q: system fontは端末ごとに見た目が変わりますか?

変わりますが、差は大きくありません。macOS/iOSはPingFang、WindowsはMicrosoft JhengHei、AndroidはNoto Sans CJKです。どれも高品質なCJK sans-serifで、読み心地の差はかなり小さいです。

Q: font-display: optionalとswapは何が違いますか?

swapは先にsystem fontを表示し、web fontの読み込み後に切り替えるため、layout shift(CLS)が起きます。optionalは100msだけfont読み込みを待ち、間に合わなければsystem fontを使い続けるので、layoutが跳ねません。


— Penchan