静的ブログの体感速度最適化 — Speculation Rules、Stale-While-Revalidate、Webフォント削除

xiyo.dev をできるだけ速いブログにすることが今回の作業の目的でした。まず SvelteKit ランタイムベースのホバー時プリロードを取り除き、Speculation Rules に置き換えました。続いて _headers に SWR 方針を適用し、検証中に見つかった Cloudflare ヘッダー直列化問題を修正しました。最後に、初回訪問時の転送量で大きな割合を占めていた Web フォントを削除しました。

この測定を行った当時、このブログは SvelteKit adapter-static + Cloudflare Workers Static Assets + csr = false で構成されていました。現在は Cloudflare adapter ベースの Worker としてデプロイし、CSR を再び有効にしています。当時はランタイム JavaScript がなく、メニューのトグルは HTML Popover API、ページ間遷移は CSS @view-transition で処理していました。

測定条件と実測サマリー

実際の本番環境 xiyo.dev を Chrome DevTools の Slow 3G エミュレーション + 375 × 812 モバイルビューポート(iPhone サイズ)で playwright により録画しました。

メトリクス Before(適用前、一部推定) After(測定当時の本番環境)
メイン CSS raw 195 KB 40 KB
メイン CSS gzip ~61 KB 7.4 KB
ビルド成果物の woff2 ファイル数 210 0
ビルド成果物のフォント容量 7.5 MB 0
ホーム初回訪問の総転送量 ~230 KB(推定) 8.7 KB
ホーム DOMContentLoaded(Slow 3G) 数秒台(推定) 510 ms
_headersimmutable 実適用 max-age 60 秒(無効化) 31 536 000 秒(1 年)

CSS とフォントの数値は、変更前ビルド成果物の実測値です。Slow 3G の読み込み時間は Before の再測定を省略した推定値です。以下では、三つの変更を順番に整理します。

第1部. Speculation Rules

SvelteKit ランタイムプリロードから Speculation Rules への移行

当初は SvelteKit のクライアントランタイムを利用して、ホバー時点のプリロードを行っていました。<body> に付く標準属性がこれを担当します。

<body data-sveltekit-preload-data="hover">

この属性は SvelteKit ランタイムによって解釈され、ホバーされたリンクの次ページデータを先読みします。ただし、プリロード一つのためだけにクライアントランタイム全体を維持する理由は弱いと判断しました。メニューのトグルは HTML Popover API、ページ遷移は CSS @view-transition で代替できる状態であり、nojs.club への登録を目標にしたことで、ランタイム全体を取り除く動機が明確になりました。

当時は csr = false によりランタイム JavaScript を削除しました。その結果、data-sveltekit-preload-data はもう解釈されませんでした。そこで代替手段として Speculation Rules API を適用しました。対応していないブラウザでは通常のナビゲーションとして動作します。

対応状況(2026-04 時点)

Speculation Rules は、実利用ベースでは Chromium 系ブラウザが中心です。Firefox は prefetch・prerender ともにまだ実装中であり(Bugzilla 1969396, 1969838)、Safari は WebKit / Safari Technology Preview で same-origin prefetch の作業が進んでいますが、stable チャンネルでの shipping 有無は公式リリースノートでは確認できませんでした。非 Chromium 環境では通常ナビゲーションへフォールバックする progressive enhancement 的な性質です。

Speculation Rules — ブラウザ標準のプリロード

Chromium ベースのブラウザでは、JavaScript なしで解釈される宣言的な JSON ルールによりプリロードを指示できます。

<script type="speculationrules">
{
  "prefetch": [...],
  "prerender": [...]
}
</script>

prefetch と prerender の違い

モード 実行範囲 クリック時の動作 メモリコスト
prefetch HTML バイトだけをディスクキャッシュに保存 パース・CSS・画像ダウンロードはクリック後に実行 低い
prerender 隠しプロセスで DOM・CSS・JS・paint まで完了 ブラウザが prerender 済み文書を有効化。PerformanceNavigationTiming.activationStart が有効化時点を示す 1 ページあたり数十〜数百 MB

prerender は完成済みの文書ツリーをメモリに保持し、クリック時点でウィンドウをそのコンテキストへ切り替えます。有効化後、最初の paint までの遅延は実測上、数 ms 程度です。

eagerness オプション別のトリガー条件

トリガー(デスクトップ) タッチデバイス
conservative pointerdown pointerdown
moderate(デフォルト) ホバーを 200 ms 以上維持、または pointerdown pointerdown
eager リンクがビューポートに入った時点 同じ
immediate ページ読み込みと同時にマッチする全リンク 同じ

moderate はホバー意図が確認されたリンクだけをプリロードするため、帯域の無駄が少なくなります。タッチデバイスにはホバーがないため pointerdown が適用されます。

このブログでの構成

投稿パスは数が多く、すべてを prerender 対象にするとメモリコストが大きくなります。一方で、ルート・/about/portfolio のような少数のランディングページは画像が重く、対象も限定的なので prerender の効果が大きいです。二つのパスグループを次のように分離しました。

<script type="speculationrules">
{
  "prefetch": [
    {
      "where": {
        "and": [
          { "href_matches": "/posts/*" },
          { "not": { "href_matches": "/nav" } }
        ]
      },
      "eagerness": "moderate"
    }
  ],
  "prerender": [
    {
      "where": {
        "and": [
          { "href_matches": "/*" },
          { "not": { "href_matches": "/posts/*" } },
          { "not": { "href_matches": "/nav" } }
        ]
      },
      "eagerness": "moderate"
    }
  ]
}
</script>

/nav は popover dialog 専用ルートで事前読み込みが不要なため、両方のルールから除外しました。

当時の構成では、サーバー側で prerender 済みの静的 HTML をそのまま配信していました。クライアント側 hydration がなかったため、有効化と同時に画面が切り替わりました。実装方式だけを見ると、AMP が提示していた即時遷移体験に似ていました。

AMP の背景と現在

AMP(Accelerated Mobile Pages)は、Google が 2015 年に発表したモバイル Web フレームワークです。Google Search は AMP ページを AMP Viewer・Signed Exchange 経由で高速に表示でき、AMP Viewer 経路では google.com/amp/... 形式の URL が表示されました。この経路はパブリッシャーのドメインがアドレスバーで隠れるため、プラットフォーム依存への批判を受けました。2021 年、Google は Page Experience アップデート により、Top Stories などの検索機能で AMP を必須要件から外しました。その後、パブリッシャーの採用規模は縮小傾向にありますが、正確な離脱時点は公式一次ソースでは確認できませんでした。

AMP が提供した体験、つまり事前レンダリング済み文書の即時表示は、Speculation Rules がパブリッシャー本来の URL とブラウザ標準の上で同じ効果として提供します。

第2部. Stale-While-Revalidate

動作

Cache-Control ヘッダーに二つのディレクティブを同時に指定します。

Cache-Control: public, max-age=0, stale-while-revalidate=604800

レスポンスは次の三つの区間として解釈されます。

  • max-age 区間 — キャッシュから即時返却。この例では 0 秒。
  • stale-while-revalidate 区間(604 800 秒 = 7 日)— キャッシュ上の期限切れレスポンスを即時返却し、バックグラウンドでオリジンを再検証。
  • 上記二つの区間の後 — オリジン再検証を同期的に行う通常レスポンス。

結果として、再訪問者は 7 日以内であればネットワーク待ちなしで以前のレスポンスを受け取り、次回訪問時に最新バージョンが反映されます。

パス別ポリシー

Cloudflare Workers Static Assets は static/_headers ファイルをネイティブにサポートします。

パス Cache-Control 意図
/_app/immutable/* max-age=31536000, immutable ハッシュ URL、1 年不変
/posts/*, /en-us/posts/*, /ja-jp/posts/* max-age=0, SWR 7日 再訪問時に即時返却 + バックグラウンド更新
*.png, *.jpg, *.webp, *.avif, *.svg, *.ico max-age 7日, SWR 30日 更新頻度が低い
/feed.xml, /sitemap.xml max-age 1時間, SWR 1日 クローラー向け XML
/, /about, /portfolio, /nav, /404, ロケールルート max-age 60秒, SWR 1時間 新規記事反映の遅延を制限

問題 — /* fallback が immutable を無力化したケース

初期の _headers 構成には、次のようなキャッチオールルールが含まれていました。

/*
  Cache-Control: public, max-age=60, stale-while-revalidate=3600

wrangler dev --localcurl -I によりパス別レスポンスを確認していたところ、ヘッダーが意図と違って直列化されることを確認しました。

$ curl -I http://localhost:8787/_app/immutable/chunks/B4d5FnLc.js
Cache-Control: public, max-age=31536000, immutable, public, max-age=60, stale-while-revalidate=3600

Cloudflare Workers Static Assets ドキュメント_headers の動作をこのように定義しています。複数のルールにマッチするリクエストは、すべてのルールのヘッダーを継承し、同じヘッダーが重複すると値がカンマで結合されます。Cloudflare Pages も同じ規則です。ただし _headers は、Worker コードや Pages Functions が直接生成したレスポンスには適用されません。このブログは静的アセットだけを配信するため、この規則がそのまま適用されます。

RFC 9111 §4.2.1 は、同じキャッシュディレクティブが重複した場合、実装が最初の値を使うか、レスポンス全体を stale とみなすよう規定しています。どちらにしても、1 年キャッシュを期待して設定した immutable は標準上信頼できない状態になります。immutable キーワードも、そのレスポンス内で同じヘッダーの別ディレクティブと衝突し、実質的な効果を失います。画像(max-age=604800 + max-age=60)と投稿(max-age=0 + max-age=60)も同じ構造的問題にさらされ、意図したキャッシュ方針が保証されませんでした。

解決 — パスごとにルール 1 個を保証

/* fallback を削除し、HTML ページを明示的に列挙しました。設計意図は _headers ファイル上部のコメントに記録しました。

# マッチするすべてのルールの同一ヘッダーはカンマで結合されます。
# パスごとに正確に一つのルールだけにマッチするよう構成しなければ、
# 重複ディレクティブによって `immutable`・長期キャッシュ方針が無効化されます。
# そのため `/*` fallback は使用しません。

/_app/immutable/*
  Cache-Control: public, max-age=31536000, immutable

/posts/*
  Cache-Control: public, max-age=0, stale-while-revalidate=604800

# ... 画像、feed、ロケール別 HTML ルール ...

/
  Cache-Control: public, max-age=60, stale-while-revalidate=3600
/about
  Cache-Control: public, max-age=60, stale-while-revalidate=3600
/portfolio
  Cache-Control: public, max-age=60, stale-while-revalidate=3600

修正後、各パスのレスポンスヘッダーは単一値として正規化されます。

$ curl -I https://xiyo.dev/_app/immutable/assets/0.D9SxcKrv.css
Cache-Control: public, max-age=31536000, immutable

$ curl -I https://xiyo.dev/posts/<slug>
Cache-Control: public, max-age=0, stale-while-revalidate=604800

$ curl -I https://xiyo.dev/
Cache-Control: public, max-age=60, stale-while-revalidate=3600

第3部. Webフォント削除

背景 — CSS 195 KB の構成分析

Speculation Rules と SWR の反映後、残っていた主要な転送量を分析しました。メイン CSS ファイルの raw サイズは 195 KB でした。Tailwind v4 のコンパイル結果としては例外的に大きいサイズです。ビルド成果物に含まれていた woff2 ファイル構成は次の通りでした。

ファミリー subset 数(このビルドでの観測値) 合計
Pretendard Variable 92 2.9 MB
Pretendard JP Variable 117 4.6 MB
Neo둥근모 Code 1 39 KB
全体 210 7.5 MB

CSS 195 KB のうち、かなりの部分は上記 210 個のファイルを参照する @font-face 宣言と unicode-range 指定でした。

参考 — 使用していた三つのフォント

Pretendard Variable길형진(orioncactus)が 2021 年に公開したオープンソース Web フォントです。Apple system-ui 環境と視覚的に互換になるよう設計されており、variable weight 45〜920 の範囲と 動的サブセット(dynamic subset) をサポートします。

Pretendard JP Variable — 上記プロジェクトの 日本語環境向けバリエーション です。日本語テキスト環境に合わせた字形を提供し、OpenType ss05 feature により韓国語向け字形を選択できます。

Neo둥근모 Code — 韓国語ドットフォント「둥근모꼴」の系譜を現代 Unicode に移植した neodgm ファミリーのコーディング用バリエーションで、Neo둥근모 にコーディング用 ligature が追加されたフォント です。このブログではコードブロック表示用に使っていました。

表の subset 数は公式配布仕様ではなく、このビルドで観測されたファイル数です。

subset システムは実際のダウンロード量を減らすが、CSS サイズは減らさない

Pretendard は Unicode 範囲別の subset に分割されており、ブラウザは @font-faceunicode-range 宣言に従って、ページ内に登場する文字範囲の subset だけをダウンロードします。個々の訪問者が 210 個のファイルすべてを受け取るわけではありません。

しかし @font-face 宣言自体は CSS 内に残ります。woff2 が実際にダウンロードされるかどうかに関係なく、CSS のパースコストと初回訪問時の転送バイトはそのまま発生します。再訪問時は SWR と immutable 方針により 0 リクエストになりますが、初回訪問者は 195 KB raw CSS をそのまま受け取る状態でした。

デザイン要素としてのフォント vs 速度 — 速度を選んだ

フォントは本文タイポグラフィの統一感を決めるデザイン要素であり、ブログ全体の視覚的アイデンティティに直接影響します。Pretendard の一貫した字幅と高さの比率は、このブログの基本レイアウトを設計する際に前提としていたタイポグラフィ基盤でもあります。デザイン上の損失があることは明らかでした。

当時は、ランタイム JavaScript を削除した後も 7.5 MB の Web フォントを維持する構成が、方針として一貫していませんでした。体感速度を最優先に置く以上、デザインの同質性より読み込み性能を優先しました。

src/app.css からフォント @import 三行を削除し、--font-sans--font-mono は OS 標準スタックに変更しました。package.json から pretendardpretendard-jp@kfonts/neodgm-code も削除しました。当時の csr = false の決定と同じ性質でした。その層に代替を重ねるのではなく、層そのものを取り除く選択でした。

結果

項目 Before After
メイン CSS raw 195 KB 40 KB(−79%)
メイン CSS gzip ~61 KB 7.4 KB(−88%)
ビルド woff2 ファイル数 210 0
初回訪問フォントネットワークリクエスト 可変(unicode-range 依存) 0

Trade-off

OS によって表示される韓国語フォントが変わります。

OS レンダリングされる韓国語フォント
macOS・iOS Apple SD Gothic Neo
Windows Malgun Gothic
Android・ChromeOS Noto Sans CJK KR
  • 諦めたもの: Pretendard の一貫した字幅と視覚的アイデンティティ、プロジェクト全体のタイポグラフィ同質性
  • 得たもの: FOIT/FOUT の不在(OS フォントは即時利用可能)、メイン CSS gzip 88% 減少、ビルド成果物 7.5 MB 減少、Tailwind が処理する CSS 入力削減によるビルド時間短縮

まとめ

三つの変更はいずれも「追加」ではなく「削除」で一貫しています。SvelteKit preload 属性はランタイムごと取り除いて Speculation Rules に置き換え、_headers/* fallback を削除してパスごとに単一ルールを保証し、Web フォント依存をすべて消して初回訪問 CSS を 1/5 に減らしました。Slow 3G・モバイルビューポート基準で、ホーム初回訪問の転送量 8.7 KB、DOMContentLoaded 510 ms は測定当時の本番環境での実測値です。

Created : 26/04/17 Modified : 26/04/18
Designed by