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

フォントの先読み指定が逆効果になり、画像が見えるのに3秒待たされた — next/font preload と motion fade-in の罠

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

5/11 に Google Fonts の @import を next/font/google に置き換えて、PageSpeed の真っ赤を一段抜けた。続編としてもう一度計測したら、まだ 3 つ問題が並んでいた。順番に潰した記録。前提は 前回の記事 を参照。

この記事でわかること

  • next/font/google で日本語フォントを preload: true にすると、HTML に 何百個もの <link rel="preload"> が注入されること
  • LCP 対象画像を motion で fade-in(initial: { opacity: 0 })すると、SSR HTML 時点で opacity 0 になって LCP が確定しない問題
  • next/font/google が自動でやってくれること / やってくれないこと(fallback フォントスタック)
  • 「指標を 1 つ直すと次の主犯が見える」という前回の続きの実演

前提:LCP / FCP / TBT って何

PageSpeed Insights / Lighthouse の数字を読むには、まず Web Vitals の 3 指標を押さえておく必要があります。

指標 何を測ってる Good Needs Improvement Poor
FCP (First Contentful Paint) 最初の文字/画像が画面に出るまで ≤ 1.8 s ≤ 3.0 s > 3.0 s
LCP (Largest Contentful Paint) ページの 最大コンテンツ要素が描画されるまで ≤ 2.5 s ≤ 4.0 s > 4.0 s
TBT (Total Blocking Time) JS がメインスレッドをブロックした合計時間 ≤ 200 ms ≤ 600 ms > 600 ms

FCP が早くても LCP が遅ければ「白から灰色になっただけで本体が出てこない」状態に見えます。だから今回も LCP を主役に据えて読んでいく。

LCP の対象になるのは

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

のうち、ビューポート内で面積が最大のもの。ページ構成によって動的に変わります。今回の LP だと「中央の綴Photo ロゴ画像」が LCP 対象。

補足:Web Vitals には他に CLS(Cumulative Layout Shift、ガタつき度合い)と INP(Interaction to Next Paint、操作応答性)もある。今回のサイトはこの 2 つはほぼ問題ないので割愛。


計測結果の整理:3 つの主犯

PageSpeed Insights のデスクトップ結果を開いたら、こうなっていた。

指標 値
パフォーマンス 89
FCP 0.9 秒
LCP 4-5 秒台(要素のレンダリング遅延 3,430ms)
TBT 60 ms
CLS 0

問題セクションには 3 つ並んでいた。

  1. レンダリングをブロックしているリクエスト ─ 推定削減 780ms
  2. 強制リフロー ─ 合計 623ms
  3. LCP の内訳 ─ 要素のレンダリング遅延が 3,430ms

スコアだけ見ると 89 で「悪くない」のだが、LCP 4 秒超は体感としても訪問者に「遅い」と感じさせるライン。1 つずつ精査することにした。


本番の HTML をそのまま curl で取ってきて、<head> 内の preload を数えたら 240 個あった。

bash
curl -sL https://tsuzuphoto.com/ \
  | grep -oE '<link[^>]+rel="preload"[^>]+as="font"[^>]+>' \
  | wc -l
# => 240

HTML の総バイト数は 136KB。中身はほぼ preload リンクで埋まっている状態。

なぜこうなったか

layout.tsx の next/font の設定はこうだった。

tsx
const zenMaru = Zen_Maru_Gothic({
    weight: ["400", "500", "700"],  // 3 weights
    subsets: ["latin"],
    display: "swap",
    variable: "--font-zen-maru",
    preload: true,                  // ← これ
});

日本語フォントは Google Fonts 側で unicode-range を使って 80 個前後のサブセットに分割されています。文字の集合(CJK 統合漢字 拡張 A、ひらがな、カタカナ、…)ごとに別ファイルになっています。

フォント実体は next/font がビルド時に置いた静的ファイル。240 本の指示は HTML の head。DB には無い。保存しない。preload: trueHTML の headpreload 240 本HTML 136 KBレスポンス本文フォントファイル3 weight × 80 分割240 個全部取るself-host の静的ファイルページの文字使うのは 1 個ヒーローは数十文字DOM(メモリ)preload: falseHTML の headフォント preload なしレスポンス本文先読み指示は書かないCSS文字を見て subset を決めるnext/font が吐いたスタイル静的ファイルフォントファイル必要な 1 個だけ取るself-host の静的ファイルDB には無い

preload は本当に必要か?

preload: true の目的は「フォントの発見を早めること」。ヒーローセクションに出る文字は数十文字で足ります。ブラウザは preload を「絶対使う」と解釈するので、メリットよりデメリットが大きいです。

対応:preload: false に変える

diff
- preload: true,
+ preload: false,

これでブラウザは「CSS 読んでから、ページに出ている文字だけ取りに行く」挙動になります。確認すると、実際にダウンロードされるフォントファイルは 1 個だけだった。

余談:「preload なしだと無駄なフォントまで取られるのでは?」と最初は思ったが、逆だった。preload なしの方が ブラウザが必要なものだけ取りに行く ので無駄がない。preload は「最初の1枚を早く見せるための加速」であって、「対象を絞る仕組み」ではない。

副次:weight を絞ると CSS チャンクも軽くなる

Zen_Maru_Gothic の CSS チャンクサイズを weight 別に見ると、

weight CSS チャンクサイズ
400 / 500 / 700 273 KB
400 / 700 約 180 KB
400 のみ 91 KB

LP では font-bold を font-serif 無しで使っている箇所がゼロ(= Zen Maru の 700 は実質未使用)だったので、weight: ["400"] のみに絞った。


主犯 ②:LCP 対象画像を motion で fade-in していた

「要素のレンダリング遅延 3,430ms」をどう読むか。

LCP の内訳を見ると

内訳 値
TTFB 0 ms
リソース読み込みの遅延 250 ms
リソースの読み込み時間 350 ms
要素のレンダリング遅延 3,430 ms

画像(綴Photo ロゴ)のファイル取得は 600ms 以内に終わっている。にもかかわらず、画面に出るまで 3.4 秒待たされている。

原因:motion の initial opacity 0 が SSR HTML に焼き込まれている

HeroSection.tsx のロゴ画像周辺がこうなっていた。

tsx
<motion.div
    initial={{ opacity: 0, scale: 0.9, y: 6 }}
    animate={{ opacity: 1, scale: 1, y: 0 }}
    transition={{ duration: 0.8, delay: textStart - 0.2 }}
>
    <Image
        src="/images/lp/ios_icon.jpeg"
        alt="綴Photo"
        priority
        ...
    />
</motion.div>

textStart は 0.3 + 8 * 0.12 + 0.1 = 1.36s。delay 1.16s、duration 0.8s で opacity 1 到達は 1.96s。

ロゴは public/images/lp/ のファイル。届いたあとはブラウザのメモリ。opacity 0 は SSR HTML。DB には無い。保存しない。SSR HTMLopacity 0 が書いてある最初から見えないレスポンス本文保存しない600msブラウザのメモリ画像ファイルは到着まだ透明で見えないpublic/images/lp/hydration は 1 秒前後delay 1.16s+ 0.8s画面opacity 1 で見える到達は 1.96sLCP 約 3.4sビューポート画像は手元にあるのに、HTML に透明と書いてある間は LCP に数えられない。透明指定は SSR HTML に焼き込まれる。DB にも設定ファイルにも残らない。

PageSpeed の「要素のレンダリング遅延 3,430ms」とほぼ一致する。

対応:LCP 対象だけ initial opacity を 1 に

diff
- initial={{ opacity: 0, scale: 0.9, y: 6 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- transition={{ duration: 0.8, delay: textStart - 0.2 }}
+ initial={{ scale: 0.94 }}
+ animate={{ scale: 1 }}
+ transition={{ duration: 0.6, delay: 0 }}

opacity は最初から 1。scale だけ 0.94→1 のさりげない演出を残す。LCP は SSR HTML 時点で確定するため、数値が一気に下がります。

ロゴ以外(テキスト・カード・CTA)は fade-in 演出をそのまま残す。これらは LCP 判定対象ではないので、演出を保ったまま OK。

副次:「全アニメ完了を 2.5 秒以内」のリデザイン

LCP 自体は SSR で確定するが、訪問者の体感として「ヒーローが完成するまで」が遅すぎた。せわしなく感じない範囲で全アニメ完了を 2.5 秒以内に着地させる、と方針を決めて定数を組み直した。

定数 旧 新
CARD_BASE_DELAY 0.3 0.1
CARD_STAGGER 0.12 0.06
CARD_ENTER_DURATION 1.1 0.85
CAPTION_BASE_DELAY 1.45 0.5
CAPTION_CARD_STAGGER 0.7 0.25
CAPTION_CHAR_STAGGER 0.07 0.04
DATE_OFFSET_AFTER_CAPTION 0.4 0.20

全アニメ完了が約 4.9 秒 → 2.9 秒に。一発のアニメ duration は 0.5〜1秒に収まっているので「ふわっと感」は残せている範囲。


主犯 ③:fallback フォントスタックが無かった

ここは PageSpeed の指標ではなく、体感の問題。

preload: false にすると本フォント到着までに数百ms 遅れます。その間ブラウザは fallback で描画するが、globals.css を見たらこうなっていた。

css
--font-sans: var(--font-zen-maru), "Zen Maru Gothic", serif;

最後の serif が fallback の終着点。serif は OS の 明朝体を意味します。Zen Maru Gothic は丸ゴシック。丸ゴシック ↔ 明朝体の切替が走ることになります。

next/font がやってくれること / やってくれないこと

next/font/google は次のことを自動でやってくれます。

  • self-hosting(フォントファイルをビルド時に取得)
  • フォントメトリクス調整(adjustFontFallback: true がデフォルト。fallback の ascent/descent を本フォントに揃えます)
  • preload リンク注入(preload: true の時)

やってくれないのは、

  • fallback フォントファミリ名の指定

これは自分で font-family スタックに書く必要があります。

対応:本フォントに近い OS フォントを並べる

css
--font-sans: var(--font-zen-maru),
    "Hiragino Maru Gothic ProN", "ヒラギノ丸ゴ ProN W4",
    "Meiryo", "メイリオ",
    system-ui, sans-serif;
--font-serif: var(--font-noto-serif),
    "Hiragino Mincho ProN", "ヒラギノ明朝 ProN W3",
    "Yu Mincho", "游明朝", "YuMincho",
    serif;
  • Mac / iOS は OS に Hiragino Maru Gothic が入っているので 最初から丸ゴシック相当で描画されます。本フォント到着時の切替がほぼ視認できません。
  • Win は丸ゴシックが OS に無いので Meiryo(角ゴシック)→ Zen Maru Gothic で多少の切替感が残ります。これは諦め。

「最終的に見えるフォント」は変えていない

ここを誤解しやすいので明示しておくと、

変更前 変更後
本フォント(最終) Zen Maru Gothic 変えていない
fallback(つなぎ) OS の明朝体(serif) OS の丸ゴシック系

最終的なフォントは変えていない。つなぎの代打を本フォントに近い見た目に揃えただけ。


結果:Before / After

3 つの主犯を順に潰した結果。

指標 Before After 変化
HTML サイズ 136 KB 77 KB -43%
フォント preload リンク数 240 個 1 個 -99%
CSS チャンク合計 627 KB 351 KB -44%
デスクトップ スコア 89 90 +1
デスクトップ LCP 〜3.4s 台 1.5s -1.9s
デスクトップ FCP - 0.9s -
デスクトップ Speed Index 2.2s 1.9s -0.3s

LCP は PageSpeed の「Good」基準(2.5 秒)以内に着地。


モバイル:別の世界の話だった

スコアが 90 になった一方で、PageSpeed のモバイル結果は別ものだった。

指標 モバイル
パフォーマンス 43
FCP 5.4 秒
LCP 8.1 秒
TBT 710 ms

低速 4G + 中位スマホ(Moto G Power)でのエミュレーション。同じサイトとは思えない数字。

ここから先は Phase 3 として、もう一段の最適化に進んだ。


Phase 3 ①:画像圧縮で 30 MB → 1.6 MB

ヒーローの実写真がそのまま public/images/lp/ に置かれていた。

ファイル 元サイズ
photo-2024-forest.jpg 8.3 MB
photo-2014-sunset.jpg 5.7 MB
demo-sea.jpg 3.9 MB
photo-2024-mimosa.jpg 2.6 MB
ios_icon.jpeg 624 KB

LP で実際に使う 12 枚を合計すると 約 30 MB。next/image は配信時に最適化するが、ソース画像が重いとビルド時の variant 生成も遅く、低速回線下では配信側の最適化の効きも弱いです。

sharp で一括 WebP 化

Next.js はすでに sharp を依存に持っているので、これを使ってビルド前の整地スクリプトを AI に書いてもらった。

js
// scripts/optimize-lp-images.mjs
import sharp from "sharp";
 
const TARGETS = [
    { src: "public/images/lp/photo-2024-mimosa.jpg", maxSize: 1800, quality: 82 },
    { src: "public/images/lp/demo-sea.jpg", maxSize: 1800, quality: 82 },
    // ...
];
 
for (const t of TARGETS) {
    const dst = t.src.replace(/\.(jpe?g|png)$/i, ".webp");
    await sharp(t.src)
        .resize(t.maxSize, t.maxSize, { fit: "inside", withoutEnlargement: true })
        .webp({ quality: t.quality })
        .toFile(dst);
}

結果はこうなった。

ファイル Before After 削減
photo-2024-forest 8.3 MB 573 KB -93%
photo-2014-sunset 5.7 MB 126 KB -98%
demo-sea 3.9 MB 70 KB -98%
photo-2024-mimosa 2.6 MB 98 KB -96%
ios_icon 624 KB 93 KB -85%
合計(12 枚) ~30 MB 1.6 MB -95%

WebP は PNG(圧縮効率)と JPEG(広い互換性)のちょうど中間の規格で、現代ブラウザならどれも対応しています。

結果:TBT が劇的に改善、LCP は据え置き

これで再計測すると、

指標 Phase 2 後 Phase 3 ① 後 変化
パフォーマンス 43 60 +17
TBT 710 ms 90 ms -620 ms ✅
LCP 8.1 秒 8.1 秒 ±0 ❌

TBT が劇的に下がった一方、LCP が変わらなかったのが面白い結果だった。「画像を 95% 軽くしたのに LCP が動かない」のは何故か?


Phase 3 ②:LCP は画像ではなく "CSS 取得時間" でブロックされていた

PageSpeed の「LCP リクエストの検出」セクションを開くと、次の指摘があった。

❌ fetchpriority=high を使用する必要があります

そして「レンダリングをブロックしているリクエスト」を見ると、

ブロック要因 継続時間(モバイル 4G)
HTML 本体 78 KB 2,250 ms
Zen Maru CSS 31 KB 750 ms
Tailwind CSS 14 KB 600 ms
Noto Serif CSS 33 KB 900 ms
合計 ~4,500 ms

低速 4G では同じ KB でも数倍の時間がかかります(4G が 1.6 Mbps スロットリングのため)。デスクトップだと一瞬で取れる 78 KB の HTML が 2.25 秒持っていかれます。

HTML はレスポンス本文。CSS と画像はサイトの静的ファイル。DB には無い。保存しない。HTML78 KBモバイル 4G で 2,250msレスポンス本文CSSZen Maru 31 KBTailwind 14 KBNoto Serif 33 KB静的ファイル画面描画は全部揃ってからLCP 8.1s 据え置きビューポート画像 public/images/lp/Phase 3 ① で 1.6 MB まで軽くした静的ファイル。届いても前段が終わるまで描けない

fetchPriority="high" を画像に明示

Next.js Image の priority プロパティは <link rel="preload" as="image"> を head に注入します。しかし fetchpriority 属性は Next.js のバージョンによって自動付与されたりされなかったりする(v16 でも今回付いていなかった)。

PageSpeed が指摘していた通り、明示的に書く。

tsx
<Image
    src="/images/lp/ios_icon.webp"
    alt="綴Photo"
    priority
    fetchPriority="high"  // ← 追加
    ...
/>

priority と fetchPriority の役割の違い

プロパティ 書き出されるもの 役割
priority <head> に <link rel="preload" as="image"> 「いますぐ取りに行って」(先読み命令)
fetchPriority="high" <img fetchpriority="high"> 「他より先に帯域を割り当てて」(順位付け)

低速回線では「同時に複数のリソースを取得 → 帯域を分け合う」状態になるので、fetchPriority で取得順を明示的にコントロールできます。

結果:Speed Index がさらに改善、LCP は微減

指標 Phase 3 ① 後 Phase 3 ② 後 変化
パフォーマンス 60 62 +2
FCP 5.6 s 5.3 s -0.3 s
LCP 8.1 s 7.8 s -0.3 s
TBT 90 ms 50 ms -40 ms
Speed Index 6.4 s 5.3 s -1.1 s ✅

fetchPriority の効果は Speed Index に最も強く効いた(-1.1 s)。LCP は -0.3 s と限定的。


なぜ LCP がまだ 7.8 秒なのか

ここで打ち切りを判断した。理由は、

残る主犯:CSS の取得時間

fetchPriority で画像取得は最優先になったが、その前段の CSS が依然 2.25 秒のブロックを作っている。CSS の中で最も重いのは Noto Serif JP(189 KB)。これを削減すれば LCP は 6 秒台に入る見込み。

ただし Noto Serif JP は LP の見出しフォントで、サイトの世界観に直結します。「フォントを軽量明朝に置き換える」「使用範囲を縮小する」のどちらも世界観への影響が読みづらく、ローンチ前マスト完了の優先度を考えるとリスクを取りに行く局面ではないと判断した。

実ユーザーの計測条件

PageSpeed Insights のモバイル計測は **低速 4G + 中位スマホ(Moto G Power)**で行われます。これは「最悪に近い条件」での参考値。実ユーザーの多くは

  • WiFi(家・職場)
  • 5G・高速 4G
  • もう少し新しいスマホ

で見ます。そこでの LCP は 2-3 秒台に収まる可能性が高いです(デスクトップが 1.6 秒なので、回線が同等なら近い数字になります)。

※ もちろん「最悪条件の数字」を改善する価値はあるが、フォント世界観 vs スコアのトレードオフは今回はフォント側を残した。

最終スコア

デスクトップ モバイル
パフォーマンス 92 62
LCP 1.6 s 7.8 s
FCP 0.8 s 5.3 s
TBT 10 ms 50 ms
Speed Index 1.6 s 5.3 s

デスクトップは Good 圏内。モバイルは Needs Improvement だが、出発点(43, LCP 8.1 s)からは確実に前進。


学び

1. next/font の preload: true は日本語に対しては罠

英語フォントなら preload は素直に効くが、日本語フォントは unicode-range で多数に分割されています。HTML 本体が肥大化して preload の旨味より副作用が勝ちます。日本語フォントは preload なしの方が良いケースが多いです。

2. motion の initial は SSR HTML に焼き込まれる

これは next/font の話ではありません。SSR する Next.js で motion / framer-motion を使う場合に起きます。

LCP 対象になる要素を fade-in する設計は、LCP 確定をそのアニメ完了まで遅らせます。LCP 対象だけは初期 opacity 1 で出す設計にします。「LCP 要素は素早く、周辺はゆったり」のフレーズで覚えておくと良いです。

3. fallback フォントスタックは自前で用意する

next/font/google は便利だが、fallback の フォントファミリ名は自動指定してくれません。var(--font-xxx), serif のような書き方だと、本フォント到着までの間に明朝体で表示されてしまいます(Zen Maru Gothic を読み込んでいる時に)。

本フォントに近い OS フォントを fallback として並べると、切替の体感がほぼ消えます。

4. 画像最適化が効くのは「画像がボトルネックの間」だけ

Phase 3 ① で LP 画像を 30 MB → 1.6 MB に圧縮した。TBT は 710 ms → 90 ms に劇的に下がったが、LCP は 8.1 秒のまま動かなかった。

「重そうなものから順に潰す」は正しいが、「直したのに数字が動かない」のは失敗じゃなく、ボトルネックが別のレイヤーに移った合図。次の主犯を探しに行くタイミング。

5. priority だけでは fetchpriority 属性は付かない

next/image の priority プロパティは「画像を <link rel="preload"> で先読みする」指示で、これと「fetchpriority="high" 属性を画像タグに書く」のは別の話。

プロパティ 役割
priority <head> に <link rel="preload" as="image"> を注入(先読み命令)
fetchPriority="high" <img> に fetchpriority="high" 属性を書く(順位付け)

低速回線では「複数リソースが帯域を取り合う」状況になるので、後者で順位を明示しないとブラウザが判断に迷います。Next.js のバージョンによって priority が自動で fetchpriority="high" を付与するケースもあるが、明示的に書く方が確実。

PageSpeed Insights の指摘事項に「fetchpriority=high を使用する必要があります」と出たら、まずここを疑います。

6. 「指標を 1 つ直すと次の主犯が見える」のループ

前回の Phase 1 では LCP の判定要素がテキスト → 画像にシフトした話を書いた。Phase 2 では preload を消したら motion の遅延が主犯になり、それを直したら今度はモバイルが丸ごと遅い(LCP 8.1s)と分かった。Phase 3 では重そうな画像を疑って圧縮したが LCP は動かず、本当の主犯は CSS 取得時間だった。

最適化は「総当たり」ではなく「ボトルネックを可視化していく層構造」のもの。1 回で全部直そうとせず、計測 → 対応 → 再計測のループで進めます。

7. 「最悪条件の数字」と「実ユーザー体感」は別物

PageSpeed Insights のモバイル計測は 低速 4G + 中位スマホ が前提。これは「最悪に近い条件」での参考値。実ユーザーの多くは WiFi や 5G で見るので、スコア 62 でも体感は十分速いことが多いです。

「スコア 90 まで上げる」ことを目的にすると、世界観を壊してまでフォントを置換する判断になりかねません。スコアは目的じゃなく診断ツールとして扱うのが筋がいいです。


まとめ

  • next/font の preload: true は日本語に対しては罠。preload: false にして「使う文字だけ取りに行く」挙動に任せます
  • LCP 対象要素を motion で fade-in しません。SSR HTML 時点で opacity 0 になり LCP が確定しません。LCP 要素だけは初期から見える状態に
  • fallback フォントスタックは自前で用意します。next/font は本フォント名しか入れてくれません
  • 画像が遅いと思っても、本当の主犯は CSS の取得時間かもしれません。画像最適化で LCP が動かなかったら次のレイヤーへ
  • priority と fetchPriority は別物。低速回線で LCP を効かせたいなら両方明示します
  • 数字が動かないのは失敗じゃなくボトルネックが移った合図。次の主犯を探します
  • 最終: HTML -43%、CSS -44%、画像 -95%、デスクトップ LCP 3.4s → 1.6s、モバイル LCP 8.1s → 7.8s(スコア 43 → 62)。フォント世界観 vs スコアのトレードオフでフォント側を残した。スコアは目的じゃなく診断ツール