p4ni.

開発の記録

Astro のウェブフォントで LCP を直す(PageSpeed 83 → 99)

· 更新 · 読了まで約21分

目次

このサイトは、構成としてはほぼ最小です。静的な Astro、クライアント側のフレームワークなし、ファーストビューに画像なし、Cloudflare Workers にデプロイ。デスクトップの PageSpeed は 99 でした。モバイルは 83

この差はまるごとウェブフォントが原因でした。ただし、経路は予想と違いました。しかも思いつく対処(とりあえず全部 preload する)は、片方の指標を直しながらもう片方を悪化させます。各段階の数字も含めて、順を追って書きます。

ここに出てくるのは2026年7月時点の計測で、当時このサイトが使っていた Newsreader と Instrument Serif が対象です。その後フォントは Inter に統一しました。手法も数式もそのままで、いまは Arial に対してメトリクスを合わせています。

Performance8399
Accessibility95100
FCP3.1秒1.6秒
LCP3.8秒1.6秒
Speed Index3.1秒1.6秒

1. Astro が CSS をインライン化するのは 4KiB まで

Lighthouse の最初の項目は「レンダリングをブロックするリクエスト」でした。削減見込みは 900ms、指しているのは 14.4KB のスタイルシート1枚です。クリティカルパスは HTML (4.6KiB) → CSS (5.3KiB 転送) で、何かが描かれる前に往復が1回入っている状態でした。

Astro のデフォルトは build.inlineStylesheets: 'auto' で、これは Vite の build.assetsInlineLimit に従います。変更していなければ 4KiB(4096バイト)です。それより大きいものには <link> が付きます。サイト全体で1枚のスタイルシートというのは、サイズに関係なくインライン化が勝つ典型例でした。

// astro.config.mjs
export default defineConfig({
  build: {
    inlineStylesheets: 'always',
  },
});

トップページのリクエストは2つから1つになりました。レンダリングブロックの監査項目はゼロになり、クリティカルパスの最大レイテンシは 1,011ms から 256ms へ落ちました。バイト数より往復1回のほうが重かった、ということです(HTML と CSS を合わせた 9.9KiB そのものが減るのは次の節の話で、インライン化はバイトを移すだけで消してはくれません)。

スタイルシートが本当に大きい場合(ページごとに CSS を吐く Tailwind のビルドなど)は、真似する前に測ってください。インライン化はそのバイトを HTML ファイル全部に複製し、ページをまたいだキャッシュを捨てることになります。コンテンツサイトで共通の 10KB なら、往復1回の節約のほうが価値があります。

2. 静的ブログのスタイルシートが 14KB もあった理由

フォントです。16個の @font-face ブロックが ファイルの57% を占めていて、しかもそのうち11個は、このサイトが一生描かない文字体系のものでした。

原因は Fontsource パッケージの読み込み方にあります。次のコードは、一見なんの問題もありません。

import '@fontsource/instrument-serif';
import '@fontsource-variable/newsreader';
import '@fontsource-variable/jetbrains-mono';

パッケージ名だけの import は、そのパッケージが持つすべてのサブセットを引っ張ってきます。キリル文字、ギリシャ文字、ベトナム語、latin-ext。ブラウザは woff2 の実体まではダウンロードしません(そのための unicode-range です)が、宣言のほうは全ページの CSS に載ります。しかもそのうち1つは、Vite がデータ URI としてインライン化できるほど小さくなっていました。JetBrains Mono のキリル拡張サブセットが base64 で 2.7KB。英語だけのサイトの読者に、それが毎回配られていたわけです。

静的な Fontsource パッケージはサブセットごとのスタイルシートを持っているので、こちらは1行で直ります。

import '@fontsource/instrument-serif/latin-400.css';
import '@fontsource/instrument-serif/latin-400-italic.css';

@fontsource-variable/* にはそれがありません。分け方が単位で(wght.csswght-italic.css)、latin だけのエントリポイントが存在しないのです。私は latin のブロックをローカルの src/styles/fonts.css に写して、そちらを import しました。OG 画像を生成したときに踏んだのと同じ罠です。可変フォントのパッケージは静的なものとは形が違うので、決めつける前に node_modules の中を覗く価値は常にあります。

結果: woff2 が15ファイルから5ファイルへ、@font-face が16ブロックから5ブロックへ。

3. LCP 要素を隠していたのはアニメーションだった

スタイルシートをインライン化したことで FCP は半分になりました。LCP は動かず、しかも LCP 要素の判定がおかしい。

Lighthouse が最大コンテンツの描画として報告してきたのは <a class="wordmark">、ヘッダーにある小さな p4ni. のロゴでした。要素のレンダリング遅延は 1,270ms。ページ上で本当に大きいテキスト、つまりトップページの <h1> とその下のリード文は、候補にすら入っていません。原因はこれでした。

@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

.fade-in {
  animation: rise 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) both;
}

animation-fill-mode: bothfrom のキーフレームをアニメーションが始まる前に適用します。そこに animation-delay: 0.05s が付いていれば、その間 <h1>opacity: 0 のままです。LCP の仕様は、描画時点で不透明度がゼロのテキストノードをきっぱり無視します。フェードインする要素が候補になるのは、何かがそれを再描画したときだけです。だから早い時点で見えていた何かに枠を譲るか、ずっと遅いタイムスタンプで勝つかのどちらかになります。私の場合、勝ったのは 1.7rem のロゴでした。

順番に現れる演出は、ファーストビューより下なら構いません。ヒーロー部分に置くと、いちばん良い LCP 候補を悪い候補と取り替えることになります。見出しとリード文からは .fade-in を外し、ページの下のほうのセクションには残しました。

4. font-display: swap と、その裏に隠れていた CLS

アニメーションを外したとたん、その陰に見えなくなっていたレイアウトシフトが表に出てきました。

score 0.1713  <section class="latest">
   cause: Web font loaded  newsreader-latin-wght-normal.woff2
   cause: Web font loaded  instrument-serif-latin-400-italic.woff2
   cause: Web font loaded  jetbrains-mono-latin-wght-normal.woff2
   cause: Web font loaded  instrument-serif-latin-400-normal.woff2

font-display: swap の標準的な挙動そのものです。ページはまずシステムのフォールバックで描かれ、本物のフォントが届き、全部が組み直される。アニメーションがそれを覆い隠していました。ずれている最中のコンテンツがまだフェードイン中だったので、シフトとして数えられていなかったのです。

思いつく対処は、最初の描画より前にフォントを届かせることです。

<link rel="preload" as="font" type="font/woff2" href={fontUrl} crossorigin />

preload を4つ入れると CLS は 0 に戻りました。そのままデプロイします。モバイルの PageSpeed は 83 から 88、FCP は 3.1秒から 1.5秒、Speed Index も 3.1秒から 1.5秒。

LCP は 3.8秒から 3.9秒。 まったく改善しませんでした。

5. あの LCP は、preload では最初から直らない

最初に立てた説明は、preload によって141KB のフォントを最大描画の前に置いてしまったというものでした。これは間違いでしたが、書いておく価値があります。そこに本当の直し方があるからです。

font-display: swap を指定していれば、テキストはフォールバックで即座に描かれます。フォントのダウンロードがそれをブロックすることはありません。web.dev は明言していますautoblock 以外の font-display であれば、LCP が追加のネットワークリクエストでブロックされることはない、と。つまりヒーローの <p class="lede"> は、どちらにしても早い段階で描かれていました。

遅れていたのは再描画のほうです。本物のフォントに入れ替わると、リード文は違うサイズで組み直され、ブラウザはそれを新しい、より遅い最大描画として記録します。LCP はバイトを待っていたのではなく、swap に引き渡されていました。preload が変えられるのは swap の起きるタイミングだけで、しかもスタイルシートをすでにインライン化していたぶん、フォントはほぼ同じタイミングで要求されていました。3.8秒が 3.9秒どまりになったのは、まさにそのためです。

抜け道は、swap を LCP のイベントにしないことです。Lighthouse も font-display の項目で言っています。読み飛ばしやすい一文です。

swap can be further optimized to mitigate layout shifts with font metric overrides.

(swap は、フォントメトリクスの上書きでレイアウトシフトを抑えるようさらに最適化できます)

本物のフォントを先に届かせるのではなく、フォールバックに同じ面積を占めさせる。そうすれば swap は目に見えなくなり、隠すものが最初から無くなります。

これをやるのが4つの CSS ディスクリプタです。size-adjust がフォールバックのグリフを拡大縮小し、ascent-override / descent-override / line-gap-override が行ボックスを固定します。比率はフォントファイルから出します。

size-adjust      = 目標フォントの平均字幅 ÷ フォールバックの平均字幅
ascent-override  = 目標フォントの ascent ÷ upm ÷ size-adjust
descent-override = |目標フォントの descent| ÷ upm ÷ size-adjust

size-adjust で割るのはごまかしではありません。MDN によれば size-adjust はグリフだけでなく「@font-face のディスクリプタで与えた上書き」も一緒に拡大縮小するので、割っておくとちょうど相殺されます。

字幅の比率を自分で計算しない

私は計算しました。OS/2.xAvgCharWidth からです。あとでブラウザ上で実測したところ、私のフォールバックはトップページの <h1> を本物より 26.8% 広く描いていました。size-adjust がまさに防ぐために存在する、その現象そのものです。OpenType の仕様は、この点を直接警告しています。

Applications should not use xAvgCharWidth for determining actual glyph advance widths.

(アプリケーションは、グリフの実際の送り幅を決めるのに xAvgCharWidth を使ってはならない)

定義がテーブルのバージョン間で変わったのが理由です。OS/2 の v0〜v2 はラテン小文字に対する出現頻度の加重平均、v3 以降は全グリフの単純な算術平均です。しかも、値を再計算しないままバージョンだけ上げられたフォントがあります。Newsreader は v4 形式の 1057/2000 を報告します。Georgia は v3 だと宣言しながら、旧来の加重平均 901/2048 を抱えたままです。片方をもう片方で割れば、自信満々で精密な、意味のない数字が手に入ります。

Newsreader → Georgiasize-adjust
OS/2.xAvgCharWidth から120.13%
実測した送り幅から96.12%

Georgia は幅の広いフォントとして有名です。Newsreader がそれより 20% 広いと主張する比率が出た時点で、手を止めるべきでした。Instrument Serif ではもっとひどく、真の値 76.49% に対して 95.92% を出荷していました。

確認は30秒で済みますし、公開前にやっておくべきでした。同じ文字列を本物のファミリーとフォールバックのファミリーで描いて、幅を比べるだけです。

const measure = (family) => {
  const s = document.createElement('span');
  s.style.cssText = `position:absolute;visibility:hidden;white-space:nowrap;
                     font-size:67px;font-family:${family}`;
  s.textContent = document.querySelector('h1').textContent;
  document.body.appendChild(s);
  const { width } = s.getBoundingClientRect();
  s.remove();
  return width;
};

await document.fonts.ready;
measure("'Newsreader Georgia Fallback'") / measure("'Newsreader Variable'"); // 1.00 前後にしたい

私の環境では 1.252 が返ってきました。いまは 1.002 です。

Capsize はラテン文字の実際の送り幅を測って、その結果を xWidthAvg と呼びます。@capsizecss/metrics にはシステムフォントの計算済みの値が入っているので、Georgia のファイルを手元に用意する必要はありません。

import { fromFile } from '@capsizecss/unpack/fs';
import { entireMetricsCollection as sys } from '@capsizecss/metrics/entireMetricsCollection';

const t = await fromFile('newsreader-latin-wght-normal.woff2');
const f = sys.georgia;

const sizeAdjust = t.xWidthAvg / t.unitsPerEm / (f.xWidthAvg / f.unitsPerEm);
const ascent = t.ascent / t.unitsPerEm / sizeAdjust;
const descent = Math.abs(t.descent) / t.unitsPerEm / sizeAdjust;

フォールバック1つにつき @font-face を1つ

size-adjust特定の1つのフォールバックに対して導かれた値です。私が最初に書いたブロックは、1つの比率のもとに4つの local() を並べていました。これでは意味がありません。Georgia 以外のフォントが当たった瞬間、幅がまた狂います。分けましょう。

@font-face {
  font-family: 'Newsreader Georgia Fallback';
  src: local('Georgia');
  size-adjust: 96.12%;
  ascent-override: 76.47%;
  descent-override: 27.57%;
  line-gap-override: 0%;
}

@font-face {
  font-family: 'Newsreader Times Fallback';
  src: local('Times New Roman'), local('Liberation Serif'), local('Tinos'), local('Nimbus Roman');
  size-adjust: 105.48%;
  ascent-override: 69.68%;
  descent-override: 25.12%;
  line-gap-override: 0%;
}

複数の local() を1つの @font-face にまとめていいのは、それらがメトリクス互換のクローンである場合だけです。Liberation Serif・Tinos・Nimbus Roman はいずれも Times New Roman の差し替え用なので、1つの比率で丸ごと面倒を見られます。この違いは、字面の印象より効いてきます。Lighthouse の実行環境は macOS ではありませんし、どれも見つからなかったフォールバックファミリーは黙って何もしません。

あとは serif などの総称ファミリー名の前に、可能性の高い順で差し込み、preload を消します。

--font-body: 'Newsreader Variable', 'Newsreader Georgia Fallback',
  'Newsreader Times Fallback', Georgia, serif;

6. 結果

指標開始時+ CSS インライン化・latin サブセット・ヒーローのアニメーション除去・preload+ メトリクスを合わせたフォールバック
Performance838899
FCP3.1秒1.5秒1.6秒
LCP3.8秒3.9秒1.6秒
Speed Index3.1秒1.5秒1.6秒
CLS0 *00.034 †

* 開始時の 0 は錯覚です。シフトは最初から 0.171 の大きさで存在していて、登場アニメーションが覆うのをやめた時点で測れるようになっただけでした(§4)。

† この 0.034 の正体は、私自身のバグでした(後述)。手法の下限ではありません。正しいメトリクスなら 0.000 前後になります。

LCP と CLS はどちらも Core Web Vitals です。PageSpeed Insights がここを重く見るのも、読者にも検索にも効いてくるのもそのためです。0.034 は CLS の「良好」のしきい値 0.1 に十分収まっています。しかもこの手法は、preload 版に比べて LCP を 2.3秒縮めました。

このときの CLS はゼロには戻らず、しばらくのあいだ私はそれを手法の代償だと説明していました。size-adjust が合わせるのは平均字幅なので、大きな見出しでは行の折り返し位置が多少ずれるはずだ、と。この説明は間違いでした。犯人は25%の誤差です。

フォールバックの @font-face ブロック以外は何も変えずに両方のバージョンをビルドし直し、ローカルで Lighthouse を3回ずつ走らせました。

CLSFCPLCPSpeed Index
OS/2.xAvgCharWidth のメトリクス0.03351,803ms1,803ms1,803ms
Capsize のメトリクス0.00041,803ms1,803ms1,803ms

描画のタイミングは完全に同じで(メトリクスの上書きはネットワークに何かを足しも引きもしません)、レイアウトシフトはほぼ消えました。これはローカルでの実行なので、絶対の数値は上の PageSpeed の表とは比べられません。意味があるのは、本番に対して PageSpeed が測った 0.034 に 0.0335 が一致している点です。そこから、0.0004 のほうも信じてよさそうだと判断しています。

正しく合わせたフォールバックは、トップページの <h1> で、Instrument Serif との差 1.1%、Newsreader との差 0.2% に収まりました。間違って合わせていたときは 26.8% と 25.2% も広く、本物のフォントが届くまで、すべての見出しで1行の4分の1がずれていた計算になります。

同じ回で Accessibility も 95 から 100 になりましたが、こちらはフォントとは無関係です。ライトテーマの2色が、一段浮いた背景(カード面)に対して 3.5:1 と 3.8:1 しかありませんでした。#857c6f → #726a5c#d44a1a → #c03f0e に暗くして、パレットの性格を変えないまま 4.5:1 を確保しています。

次に同じことをやるなら

preload より先にメトリクスの上書きへ手を伸ばす。 preload はフォントの swap に先回りして隠す手段なので、LCP がすでに swap に引き渡されている状況では何の役にも立ちません。メトリクスを合わせたフォールバックは CSS 40行ほどで、帯域はまったく食いません。それでも遅れて届くものがあるときに、はじめて preload を足します。

登場アニメーションが LCP に何をしているか確かめる。 opacity: 0 から始まるものはすべて(animation-fill-mode: both、IntersectionObserver で切り替える .is-visible クラス、たいていのスクロール連動ライブラリ)、何かが再描画するまでその要素を候補から外します。これは黙って行われる取引で、監査が教えてくれるのはどの要素が勝ったかだけです。どの要素が勝つべきだったかは決して出てきません。

ライブラリを使う。 私は読んだ式からメトリクスの計算を手で書き、仕様が読むなと言っているフィールドからそれらしい数字を取り出して、そのまま出荷しました。@capsizecss/unpackfontaine が存在するのは、これが見た目より厄介だからです。ブログに載っている式は簡単なほうの半分で、狂うのは入力値のほうです。

node_modules の中を見る。 ここで踏んだ問題のうち2つは、パッケージのほうが妥当で、私の思い込みが合っていなかっただけでした。Astro の 4KiB というインライン化のしきい値と、Fontsource の可変フォントパッケージが軸単位で分かれていること。どちらもドキュメントに書いてあります。そしてどちらも、勘で当てられるものではありません。

この手の修正で気に入っているのはそこです。どのプロジェクトにも持ち歩ける40行で、しかも読者には何のコストもかかりません。