紡Book の勉強ノートに戻る
技術ブログ2026.05.11

フォントの読み込み方を変えたら、速度スコアが悪化「したように見えた」 — Google Fonts の @import を next/font へ

パフォーマンスNext.jsWeb VitalsLCPnext/font

「指標が改善 + 別の指標が悪化」のとき、本当に直すべきかどうかを判断するには、何が変わったかを読み解く必要がある。フォントを @import から next/font/google に移したら、TBT は劇的に改善したけど LCP が悪化したように見えた。それを「失敗」と切り捨てる前に何を考えたかの記録。

この記事でわかること

  • CSS の @import でフォント読み込むと 何がブロックされるか
  • next/font/google がやっている3 つの最適化(self-host / preload / unicode-range)
  • 「指標が良くなった + 別の指標が悪くなった」ときの読み方
  • LCP(Largest Contentful Paint)の判定要素はコンテンツによって動的に変わります

きっかけ:PageSpeed Insights が真っ赤だった

LP のパフォーマンス計測をしたら、こんな結果だった。

指標 モバイル デスクトップ
パフォーマンス 57 56
First Contentful Paint 8.2 秒 1.7 秒
Largest Contentful Paint 10.3 秒 2.2 秒
Total Blocking Time 0 ミリ秒 430 ミリ秒
Speed Index 8.2 秒 3.9 秒

モバイルの LCP 10.3 秒 はかなりまずい。Google の基準(2.5 秒以内が「良い」)の 4 倍超え。


主犯特定:CSS の @import で 5 ファミリーの日本語フォント

globals.css の 1 行目にこれがあった。

css
@import url("https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;500;700&family=Noto+Serif+JP:wght@400;500;700&family=Klee+One:wght@400;600&family=Yomogi&family=Zen+Kurenaido&display=swap");

問題点を整理すると、

1. CSS の @import はレンダリングをブロックする

ブラウザは HTML をパースしながら CSS を見つけ、その中の @import を 直列に解決します。@import を 1 つ挟むと「もう 1 ラウンドトリップ」が増えます。

フォントの実体は Google の CDN。自サイトのファイルにも DB にも無い。保存しない。ブラウザHTML をパースCSS を見つける描画はまだしない直列globals.css1行目の @importリポの CSS ファイルフォント実体は無いもう1往復fonts.googleapis.com5ファミリーの実体外部 CDN保存しない毎回取りに行くCSS を読み終わってから @import を解決する。描画は最後まで待たされる。どのページでもこの経路を通る。自サイトにはフォントファイルが無い。

2. fonts.googleapis.com への DNS lookup + 接続待ち

外部ドメインへの接続は

  • DNS lookup
  • TCP 接続
  • TLS ハンドシェイク

のオーバーヘッドが乗ります。同じドメインなら接続を再利用できるが、初回訪問では数百ミリ秒取られます。

3. 5 ファミリー × 日本語

日本語フォントは1ファミリーで数百 KB〜MB 級。unicode-range で動的にサブセットが返ってくるが、それでも 5 ファミリー並べれば余裕で MB 級。

4. next/font 未使用 = self-host されない / preload されない

next/font/google を使うと、Google Fonts のファイルを ビルド時に取得して self-hostしてくれます。preload も自動。CSS variable 経由で参照するので Tailwind の --font-sans 等にもそのまま流せます。


対応:全フォントを next/font/google に移行

共通フォント(LP・blog 全体)

app/layout.tsx:

tsx
import { Zen_Maru_Gothic, Noto_Serif_JP } from "next/font/google";
 
const zenMaru = Zen_Maru_Gothic({
    weight: ["400", "500", "700"],
    subsets: ["latin"],
    display: "swap",
    variable: "--font-zen-maru",
    preload: true,
});
 
const notoSerifJP = Noto_Serif_JP({
    weight: ["400", "500", "700"],
    subsets: ["latin"],
    display: "swap",
    variable: "--font-noto-serif",
    preload: false, // 主役は Zen Maru、Noto Serif は preload しない
});
 
export default function RootLayout({ children }) {
    return (
        <html lang="ja" className={`${zenMaru.variable} ${notoSerifJP.variable}`}>
            <body>{children}</body>
        </html>
    );
}

ページスコープのフォント

精査したら、Klee One / Yomogi / Zen Kurenaido は /creation-notes/design ページでだけ使われていた。LP には不要なのに毎回 DL されていた構造。

app/creation-notes/design/layout.tsx を作って、そのページ配下でだけロードするようにした。

tsx
import { Klee_One, Yomogi, Zen_Kurenaido } from "next/font/google";
 
const klee = Klee_One({ weight: ["400", "600"], subsets: ["latin"], display: "swap", variable: "--font-klee", preload: false });
const yomogi = Yomogi({ weight: ["400"], subsets: ["latin"], display: "swap", variable: "--font-yomogi", preload: false });
const kurenaido = Zen_Kurenaido({ weight: ["400"], subsets: ["latin"], display: "swap", variable: "--font-kurenaido", preload: false });
 
export default function DesignLayout({ children }) {
    return (
        <div className={`${klee.variable} ${yomogi.variable} ${kurenaido.variable}`}>
            {children}
        </div>
    );
}
実体はビルド成果物の静的ファイル。宣言は layout のソース。DB にもメモリにも無い。全ページapp/layout.tsxZen Maru Gothic / Noto Serif JP宣言はソースファイル実体は自サイトの静的ファイルhtml に CSS 変数。保存しないLP はここの2ファミリーだけ/creation-notes/design だけapp/creation-notes/design/layout.tsxKlee One / Yomogi / Zen Kurenaido宣言はソースファイル実体は自サイトの静的ファイルdiv に CSS 変数。保存しないLP は読まない旧の globals.css の @import は消した。5ファミリーを全ページで取る経路は無い。next/font はビルド時に Google から取って自サイトへ置く関所で、置き場所ではない。

font-family 直書きを CSS variable 経由に書き換え

diff
- fontFamily: "'Noto Serif JP', serif"
+ fontFamily: "var(--font-noto-serif), serif"

next/font は名前ではなくハッシュ化されたクラス名でフォントを管理するため、直書きで 'Noto Serif JP' と書いてしまうと next/font 経由のフォントが当たりません。CSS variable 経由にすると確実に next/font のフォントが解決されます。


結果:改善した指標と悪化した指標が混ざった

Vercel にデプロイして PageSpeed を再計測したら、こうなった。

指標 旧 新 評価
デスクトップ パフォーマンス 56 73 ✅ +17
デスクトップ FCP 1.7s 1.0s ✅
デスクトップ TBT 430ms 50ms ✅ -380ms
デスクトップ Speed Index 3.9s 1.8s ✅ -2.1s
デスクトップ LCP 2.2s 5.0s ❌ +2.8s
モバイル FCP 8.2s 5.8s ✅ -2.4s
モバイル LCP 10.3s 29.4s ❌❌ +19.1s
モバイル パフォーマンス 57 50 ❌ -7

最初は「LCP がモバイルで 10秒 → 29秒に悪化した!失敗だ!」と思った。

でも他の指標を見ると、

  • TBT が 430ms → 50ms に劇的改善(フォント parser blocking が消えた決定的証拠)
  • FCP が改善(モバイル -2.4 秒 / デスクトップ -0.7 秒)
  • Speed Index が改善(デスクトップ -2.1 秒)

これら全部「実体が速くなってる」ことを示している。じゃあ LCP は何で悪化した?


仮説:LCP の判定要素が「テキスト」から「画像」にシフトした

LCP(Largest Contentful Paint)は、ビューポート内で最大の要素の描画完了時刻を測ります。判定対象になるのは

  • 画像(<img> / 背景画像 / <picture>)
  • 動画のポスター画像
  • ブロックレベル要素のテキストノード

何が「最大」になるかは、ページの構成によって動的に変わります。

旧(フォント未読込)

  • 日本語フォントが読み込まれる前は、ブラウザはフォールバックフォントで描画します。フォールバックフォントが小さい・幅が違うと、テキスト要素のレイアウト面積が変わります。
  • フォントが遅延すると、テキストの最終確定までの時間が伸びます。**「フォントが遅すぎて LCP がテキストの遅延として観測されていた」**可能性があります。

新(フォント即表示)

  • フォントが preload + self-host で即座に描画されるため、テキスト要素はすぐに確定します。
  • これまで隠れていた「画像が遅い」という問題が、フォント問題に隠れていたために観測されていなかっただけで、今回ようやく真の主犯が露出した。
LCP の数字は PageSpeed の計測。DB にもファイルにも保存しない。測る対象が変わる。旧・フォント未読込LCP 要素はテキストフォントが Google から届くまで待つテキスト確定が遅い画像の遅さは観測されない計測は保存しない新・フォント即表示LCP 要素は polaroid 画像HeroSection の写真ファイルフォントは自サイトの静的ファイルテキストは先に確定する画像の遅さが露出する数値が悪化したように見えても、測っている対象がテキストから画像に変わっただけである。失敗ではなく、次の主犯が見えた。

学び:指標は「絶対値」ではなく「何を測っているか」で読む

1. 「指標が悪化 = 失敗」とは限らない

指標が悪化したように見えても、それまで隠れていた問題が観測可能になっただけのケースがあります。今回がまさにそれ。

判定するには、

  • 他の指標がどう動いたか
  • 改修で何の挙動が変わったか
  • 指標の 判定要素自体が変わった可能性

をセットで見る必要があります。

2. パフォーマンス改善は「ボトルネックの可視化」

最適化は 1 回で終わりません。1 つ直すと次のボトルネックが見えてきます。フォントを直したら次は画像、画像を直したら次は JS バンドル、というように。

「全部直してから計測」ではなく、**「1 つ直して、計測して、次の主犯を探す」**のループが筋がいいです。

3. CSS @import は本当に避ける

CSS @import でフォントを読み込むのは「とにかく動かす」段階では便利だけど、本番運用では

  • レンダリングブロッキング
  • 直列 DL
  • 外部ドメインへの DNS lookup
  • preload が効かない

の 4 重苦になります。Next.js なら next/font/google が ほぼ無条件で正解。


次の一歩

Hero 画像(HeroSection.tsx の polaroid 8 枚)が次のフェーズの主犯候補。

  • Lighthouse 詳細レポートで実 LCP 要素を特定する
  • ソース画像のサイズが過剰(600×600)なら、表示サイズに合わせて縮小
  • priority の設計見直し(現在は最初の 2 枚だけ priority)
  • モバイルでカード枚数を絞る選択肢

このループで、たぶんあと 1〜2 回の最適化フェーズが必要になる。LCP の数値だけ追いかけずに、毎回「何の主犯が露出したか」を読むようにする。


まとめ

  • Google Fonts を CSS @import で読み込むのは性能的に最悪です。next/font/google に移行すれば self-host + preload + unicode-range で 3 重に最適化されます。
  • 改修後に「指標が改善 + 別の指標が悪化」が混在したら、LCP / FCP / TBT がそれぞれ何を測っているかを切り分けて読みます。
  • 特に LCP は判定要素が動的に変わります。「テキストが LCP 要素」だったのが「画像が LCP 要素」にシフトすると、数値が悪化したように見えるが、それは真の主犯が露出した証拠。
  • パフォーマンス改善は 1 つ直すたびに次の主犯が見える「層構造」のもの。1 回で終わらせようとしません。