IT技術ブログ

Webフォント(Google Fonts)の読み込みを描画を止めない形に変えて、モバイルのLCPを約2秒にした記録

Google FontsのCSSが描画を止めていたので、preloadとonloadで後から当てる形に変え、モバイルのLCPを3.4〜4.0秒から約2秒にしました。変更前後のビルドで測り直した数字と、文字の切り替わりやずれの確かめ方も載せます。

この記事の結論:Google FontsのCSSを<link rel="stylesheet">で読むと、display=swapを付けていても、そのCSSが届くまで最初の描画が止まります。このブログでは、これを<link rel="preload" as="style" onload="this.onload=null;this.rel='stylesheet'">と<noscript>の組み合わせに変え、CSSを描画のあとに当てる形にしました。Lighthouse(モバイルの設定)で、トップページのLCPは3.4〜3.8秒から1.9秒、記事ページは3.5〜4.0秒から1.9〜2.0秒になり、CLSは変わりませんでした。代わりに、最初は端末の書体で文字が出て、Webフォントが届いたところで切り替わります。

この記事は、2026年10月4日にこのブログ(Astroで書き出してCloudflare Pagesで公開)で行った変更の記録です。変更したのはsrc/layouts/Base.astroの3行だけで、変更の前と後のコミットをそれぞれビルドし直し、同じ手順で測り直した数字も載せています。

何が描画を止めていたか

止めていたのは、フォントのファイルではなく、Google FontsのCSSのファイルです。Lighthouseの「レンダリングを妨げるリソース」に、このCSSが約1.4秒と出ていました。

変更する前の<head>は、次のとおりです。display=swapは付けていました。

変更前(Base.astro の一部)html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Noto+Sans+JP:wght@400;700&family=Noto+Serif+JP:wght@600&display=swap" />

display=swapは、CSSの中に書かれるfont-displayの値を決めるものです。フォントのファイルが届くまで、端末の書体で文字を先に出すという指定で、フォントのファイルの待ち方には効きます。一方、@font-faceを並べたCSSそのものは、普通のstylesheetとして読む限り、届くまで最初の描画を待たせます。web.devのフォントの手引きにも、ブラウザはCSSを全部読み終えるまで、どのフォントが要るかを知ることができないと書かれています。

このCSSは、日本語の書体を2つ含むため大きくなります。今日取り直したところ、@font-faceの数は377個(Noto Sans JPが248、Noto Serif JPが124、IBM Plex Monoが5)、転送量は約92KBでした。別のドメインから、これだけの大きさのCSSを、最初の描画の前に必ず取りに行っていたことになります。

変えたのは3行:preloadで取りに行き、届いたら当てる

CSSはpreloadで早めに取りに行き、届いた時点でrelをstylesheetに切り替えて当てます。JavaScriptが動かない環境のために、同じCSSを<noscript>にも書きます。

変更後(Base.astro の一部)html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Webフォントは描画を止めないよう後から適用する(文字は先に端末のフォントで出し、届いたら切り替える) -->
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Noto+Sans+JP:wght@400;700&family=Noto+Serif+JP:wght@600&display=swap" onload="this.onload=null;this.rel='stylesheet'" />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400&family=Noto+Sans+JP:wght@400;700&family=Noto+Serif+JP:wght@600&display=swap" /></noscript>

それぞれの役割は次のとおりです。

部分 役割
rel="preload" as="style" CSSを高い優先度で取りに行くが、当てはしない。MDNの説明でも、preloadはダウンロードしてキャッシュするだけで、適用も実行もしないとされている
onload="...this.rel='stylesheet'" 届いたら stylesheet に切り替えて当てる。描画はこれを待たずに進む
this.onload=null rel を切り替えたあとに、ブラウザによって onload がもう一度呼ばれるのを防ぐ(web.devの手引きの説明)
<noscript> JavaScriptが動かない環境では、従来どおり stylesheet として読む
preconnect(2行) 変更前から置いていたもの。CSSとフォントのファイルは別のドメインから届くので、両方に接続を先に始めておく。フォント側は匿名のCORSで取りに行くため crossorigin を付ける

この形は、web.devの「重要でないCSSを後回しにする」の手引きに載っている書き方そのものです。同じ手引きには、本番ではloadCSSのような関数を使うことを勧める、という注意と、onloadの中に書くJavaScriptがContent Security Policy(CSP)と衝突することがある、という注意も書かれています。このブログはCSPのヘッダーを付けていないこと、切り替えが1か所だけであることから、関数は使わずに属性で書きました。CSPでscript-srcを絞っているサイトでは、この1行がそのままでは動かないことがあります。

測り直した結果:LCPは約2秒、CLSは変わらず

変更の前と後のコミットをビルドし直して、同じ条件で3回ずつ測ったところ、LCPはトップページで1.9秒、記事ページで1.9〜2.0秒になりました。CLSは変更の前後で同じでした。

測ったのは、トップページと、本文が長い記事ページ(/techacademy/)の2つです。数字はLighthouse 12のモバイルの設定(画面幅412px、回線とCPUを遅くした想定)で、手元のMacで静的なサーバーを立てて測っています。

ページ 回 変更前の点数 変更前のFCP / LCP 変更後の点数 変更後のFCP / LCP
トップ 1 86 2.1秒 / 3.8秒 99 1.4秒 / 1.9秒
トップ 2 89 2.1秒 / 3.4秒 99 1.4秒 / 1.9秒
トップ 3 89 2.1秒 / 3.5秒 99 1.4秒 / 1.9秒
記事 1 84 2.4秒 / 4.0秒 98 1.7秒 / 2.0秒
記事 2 87 2.4秒 / 3.5秒 99 1.7秒 / 1.9秒
記事 3 87 2.4秒 / 3.6秒 99 1.7秒 / 2.0秒

CLSは、トップページが変更の前後とも0、記事ページが前後とも0.001でした。最初の描画を妨げるリソースの欄は、変更前はGoogle FontsのCSS(約1.37秒)とサイトのCSS(約0.9秒)の2つで、変更後はサイトのCSSの1つだけになりました。

この表は、記事を書くために2026年10月4日22時45分ごろに測り直したものです。変更を入れたときにも同じ手順で2回ずつ測っていて、そのときの数字は、変更前がトップページ84点と95点(LCP 3.9秒と2.5秒)、記事ページ85点と87点(LCP 3.9秒と3.6秒)、変更後がトップページ99点・99点(LCP 1.9秒)、記事ページ98点・98点(LCP 2.0秒)でした。変更前のトップページの2回目だけ大きく良い値が出ていますが、測り直した3回では再現せず、変更前は3.4〜3.9秒、変更後は1.9秒という向きは、2回の測定でそろっています。

サイトのCSSの待ち時間が、変更時の記録(約0.36秒)と測り直し(約0.9秒)で違うのは、測った場所の違いです。測り直しは手元の簡易なサーバーで、CSSを圧縮せずに返していたため大きく出ました。本番のCloudflareで測ると約0.35秒で、変更時の記録と同じでした。

本番での数字と、1回目だけ悪く出る測定

デプロイしたあと、本番のURLでも測りました。トップページは1回目が88点、2回目が100点(LCP 1.4秒)、記事ページは96点と98点(LCP 2.4秒と2.0秒)でした。測り直した22時48分には、トップページが97点と100点(LCP 2.2秒と1.3秒)、記事ページが99点と100点(LCP 1.5秒と1.5秒)です。

本番の数字は、1回目が悪く出る傾向がありました。フォントの読み込みを変える前の本番で、作業の最初に測ったトップページの1回目は57点(LCP 10.6秒)で、続けて測り直すと88〜89点に戻りました。CDNの中継点にまだページが置かれていない状態に当たったものと見ていますが、原因を突き止めたわけではありません。1回の測定で良し悪しを決めず、続けて数回測って並べるのが確実です。

何が速くなったのか:LCPの写真が描画を待たなくなった

LCPの要素は、トップページも記事ページも表紙の画像でした。画像の取り込み自体は変わらず、取り込んだあと画面に出るまでの待ちが短くなりました。

手元のビルドを測ったLighthouseの「LCPの内訳」で、遅くしない状態で実際にかかった時間を見ると、画像を取り込み終えてから描画されるまでの時間(要素の描画の遅延)は、変更前が240〜250ミリ秒、変更後が37〜52ミリ秒でした。画像そのものの取り込みにかかった時間は、前後でほとんど同じです。画像は届いていたのに、Google FontsのCSSを待っていたため、画面に出せなかったということになります。

Lighthouseのモバイルの設定では、この待ちを、遅い回線とCPUを想定して数字に直します。そのため、実際の待ちは数百ミリ秒でも、点数の上ではLCPが1.5〜2秒ほど縮んでいます。実際の利用者の環境でどれだけ縮んだかは、Search ConsoleのCore Web Vitalsのレポートに数字がたまるのを待って確かめます。

引き換えになったこと:文字の切り替わりと、ずれの確かめ方

引き換えに、最初は端末の書体で文字が出て、Webフォントが届いたところで書体が切り替わります(FOUTと呼ばれます)。切り替わりで起きる画面のずれは、Lighthouseのレイアウトシフトの項目で、原因ごとに確かめました。

切り替わりそのものは、変更前からdisplay=swapで起きていました。変更前は、CSSを待つあいだ何も描画されず、CSSが届いたあとにフォントのファイルを待つ間だけ端末の書体が出ていました。変更後は、CSSを待つあいだも端末の書体で文字が出るので、端末の書体が見えている時間が長くなります。

書体を後から差し替えると、文字の幅や行の高さが変わって、まわりの要素が動くことがあります。web.devのCLSの手引きも、Webフォントをずれの原因の一つに挙げています。このブログでは、Lighthouseのレイアウトシフトの項目で、原因が「Web font loaded」と書かれたずれを拾って比べました。

ページ 変更前 変更後
トップ サイト名の文字で0.0001 トップの見出しで0.0004
記事 記事の見出しで0.0008と0.0003 記事の見出しで0.0008と0.0003

どれも、Core Web VitalsでCLSの「良好」の目安とされる0.1に比べて、数百分の一の値です。ずれが小さく収まった理由は、書体の指定の順番にあると見ています。このブログの本文の書体は、ヒラギノを先に、Noto Sans JPを後に並べているので、ヒラギノのあるMacやiPhoneでは本文の書体は切り替わりません。切り替わるのは、明朝の見出し(Noto Serif JP。届くまではヒラギノ明朝などが出る)と、コードの書体くらいです。

global.css の書体の指定(抜粋)css
--font: 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', 'BIZ UDPGothic', Meiryo, system-ui, sans-serif;
--serif: 'Noto Serif JP', 'Hiragino Mincho ProN', 'Yu Mincho', 'YuMincho', serif;
--mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;

裏を返すと、Lighthouseを動かしたMacは、本文がWebフォントに切り替わらない環境です。ヒラギノの無いAndroidやWindowsでは、本文もNoto Sans JPに切り替わるため、ずれがもっと大きく出るおそれがあります。そこでは測っていないので、Search ConsoleのCLSの数字がたまった時点で、端末の種類ごとに見直す予定です。ずれが目立つようなら、web.devが挙げているsize-adjustなどで代わりの書体の寸法を寄せるか、display=optionalで間に合わなければ切り替えない形を検討します。

フォントを自分のサーバーに置かなかった理由

自分のサーバーに置く案もありましたが、日本語の書体を細かく分けて配る仕組みを自分で持つことになるため、今回は見送りました。読み込み方を変えるだけで、描画を止める原因は取り除けたからです。

Google Fontsは、日本語の書体を文字の範囲ごとに細かく分けて配っています。ブラウザは、ページで使っている文字が含まれる分だけを取りに行きます(web.devの手引きのunicode-rangeの説明)。手元のビルドを測ったLighthouseの通信の記録では、トップページで取りに行ったフォントのファイルは17個(約310KB)、記事ページでは22個(約386KB)でした。自分で置くなら、この分け方を自分で作り、書体の更新にも合わせ続ける必要があります。全部の文字を1つのファイルにすれば、そのぶん大きなファイルを最初に取りに行くことになります。

速さの面でも、自分で置けば必ず速くなるとは限りません。web.devの手引きでは、自分で置く場合と外部の配信元を使う場合の差ははっきりしない、と書かれています。web.devは、フォントの指定をCSSのファイルに分けずに<head>の中に直接書くことも勧めていますが、このブログで使うGoogle FontsのCSSは圧縮しない状態で約349KBあり、各ページのHTMLに入れるには大きすぎます。

Google Fontsの説明にあるtext=の指定(使う文字だけを書いて渡すと、ファイルを最大で90%ほど小さくできる)は、サイト名のように決まった文字だけに使う書体なら有効です。このブログの見出しの明朝は記事ごとに文字が変わるので、今回は使っていません。

測り方:変更前後のコミットをビルドして、同じ条件で並べる

変更の前後を比べるときは、本番の数字どうしではなく、前後のコミットを手元でビルドして同じ条件で測ると、ほかの要因を外せます。使った手順を、そのまま載せます。

前後のコミットをビルドして測るbash
# 変更前(0be5f54 の1つ前)と変更後(0be5f54)を、別のフォルダに取り出してビルドする
git worktree add --detach ../wt-before 0be5f54^
git worktree add --detach ../wt-after 0be5f54
for w in before after; do
  (cd ../wt-$w && ln -s "$OLDPWD/node_modules" node_modules && npx astro build)
done

# それぞれの dist を別のポートで配る
(cd ../wt-before/dist && python3 -m http.server 8101) &
(cd ../wt-after/dist && python3 -m http.server 8102) &

# 3回ずつ、トップページと記事ページを Lighthouse(既定のモバイルの設定)で測る
mkdir -p lh
for i in 1 2 3; do
  for v in before:8101 after:8102; do
    name=${v%%:*}; port=${v##*:}
    for pg in home: techacademy:techacademy/; do
      pn=${pg%%:*}; up=${pg#*:}
      npx lighthouse "http://localhost:$port/$up" --quiet --chrome-flags="--headless=new" \
        --only-categories=performance --output=json --output-path="lh/$name-$pn-$i.json"
    done
  done
done

結果のJSONから、点数とFCP・LCP・CLSを並べるのは、次の小さなスクリプトです。

summarize.mjsjs
// lh/ にある Lighthouse の JSON から、点数・FCP・LCP・CLS・描画を妨げるリソースを並べる
import fs from 'node:fs';

for (const f of fs.readdirSync('lh').filter((f) => f.endsWith('.json')).sort()) {
  const r = JSON.parse(fs.readFileSync(`lh/${f}`, 'utf8'));
  const a = r.audits;
  const blocking = (a['render-blocking-resources']?.details?.items ?? [])
    .map((i) => `${new URL(i.url).host}${new URL(i.url).pathname.slice(0, 20)} ${Math.round(i.wastedMs)}ms`)
    .join(', ');
  console.log(
    f.replace('.json', ''),
    Math.round(r.categories.performance.score * 100),
    'FCP', a['first-contentful-paint'].displayValue,
    'LCP', a['largest-contentful-paint'].displayValue,
    'CLS', a['cumulative-layout-shift'].displayValue,
    '|', blocking || '(なし)',
  );
}

python3 -m http.serverは圧縮もHTTP/2もしない簡易なサーバーなので、本番より遅めに出ます。前後で同じサーバーを使っているので、比べる用途には足ります。絶対の値は、本番のURLで測った数字で確かめます。

表示の速さは、写真の大きさや形式でも大きく変わります。写真の側の決め方は、式場サイトの写真を速く見せる記事に、予約ページでのINP・LCP・CLSの切り分け方は、フェア予約ページのCore Web Vitalsの記事にまとめています。日本語の文字の見え方では、書体のほかに、スマホで単語の途中で改行される崩れもよく起きます。その直し方は、会社のコラム日本語が単語の途中で改行される原因と、Safariでも崩れない直し方に書いています。

株式会社bundlyzeでは、ホームページ・LP制作で、スマホとパソコンでの表示、フォームの送信、計測の設定を確かめてから公開しています。サイトの作り直しや速さの改善を相談したい場合は、ホームページ制作のページに進め方を載せています。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2
Astro 7.3.5
Lighthouse 12.8.2(既定のモバイルの設定。画面幅412px、回線とCPUを遅くした想定で数字に直す方式)
ブラウザ Google Chrome 154(ヘッドレス)
手元のサーバー Python 3.9.6 のhttp.server
測ったもの コミット0be5f54の1つ前と0be5f54をそれぞれビルドしたdist(3回ずつ)と、本番のURL(2回ずつ)

AndroidやWindowsの実機での表示、JavaScriptを切ったブラウザでの<noscript>の動き、Safariでの見え方、実際の利用者の数字(Search ConsoleのCore Web Vitals)は、この記事を書いた時点では確かめていません。実際の利用者の数字は、たまるまでに日数がかかるため、あとで見直します。

参照した公式ドキュメント(確認日:2026年10月4日)

  • web.dev「重要でない CSS を後回しにする」:preloadとonloadで後からstylesheetに切り替える書き方、this.onload=nullの理由、<noscript>での代わり、本番ではloadCSSのような関数を勧めること、CSPとの衝突
  • web.dev「フォントに関するおすすめの方法」:CSSを読み終えるまでフォントが要るか分からないこと、preconnectで別々のドメインに接続しておくこと、unicode-rangeでの分割、自分で置く場合との差ははっきりしないこと、font-display: swapの性質、フォントの指定を<head>に直接書くこと
  • web.dev「CLS を最適化する」:Webフォントの切り替わりでずれが起きること、font-display: optional、size-adjustなどで代わりの書体を寄せること
  • MDN「rel=preload」:preloadは取りに行ってキャッシュするだけで適用しないこと、as="style"、フォントは匿名のCORSで取りに行くこと
  • Google Fonts「CSS API update」:displayの指定でfont-displayを決めること、text=で使う文字だけを渡すとファイルを小さくできること、使う太さを絞ること

よくある質問

URLに display=swap を付けていれば、Google Fontsは描画を止めないのではありませんか?

止めます。display=swap が決めるのは、フォントのファイルが届くまで文字をどう出すかです。その前の、@font-face を書いたCSSのファイルは、普通の stylesheet として読む限り、届くまで最初の描画を待たせます。このブログでも display=swap は変更の前から付いていて、それでもLighthouseはGoogle FontsのCSSを、描画を妨げるリソースとして約1.4秒と出していました。

後からフォントを当てると、画面がずれてCLSが悪くなりませんか?

ずれは起きますが、このブログでは小さく収まりました。Lighthouseのレイアウトシフトの項目を見ると、原因が「Web font loaded」のずれは、トップページの見出しで0.0004、記事ページの見出しで0.0011でした。ページ全体のCLSは、変更の前後とも0〜0.001で変わっていません。ただし測ったのはMacで、本文の書体をWebフォントに頼る端末(ヒラギノの無いAndroidやWindows)では確かめていません。

JavaScriptを切っている人には、フォントが当たらなくなりますか?

当たります。onload で rel を切り替える書き方はJavaScriptで動くので、同じCSSを noscript の中に普通の stylesheet として書いておき、JavaScriptが動かない環境ではそちらが読まれるようにしています。web.dev の手引きにも、この noscript の書き方が載っています。ただし、JavaScriptを切ったブラウザでの表示は、この記事のためには確かめていません。

フォントを自分のサーバーに置いたほうが速くなりませんか?

必ずしも速くなりません。web.dev の手引きでも、自分で置くか外部の配信元を使うかの差ははっきりしないと書かれています。日本語のフォントは文字が多く、Google Fontsは文字の範囲ごとに細かく分けたファイルを配っていて、このブログが読み込むGoogle FontsのCSSには377個の分け方が書かれていました。自分で置くなら、同じ分け方を自分で作って保つ必要があるため、今回は配信元はそのままにして、読み込み方だけを変えました。