ハッシュ付きアセットのブラウザキャッシュ
コンテンツハッシュ付きバンドルはデフォルトでは長期キャッシュされない -- _headers の immutable ルール、103 Early Hints、そして CSS 全文インライン化が誤った対処である理由
Workers Static Assets は、すべてのアセットをプラットフォームデフォルトの max-age=0 で配信する -- styles-<hash>.css のような、URL が同じなら内容が変わりようのないコンテンツハッシュ付きバンドルも例外ではない。結果として、実質的にナビゲーションのたびに、本来無料であるべきファイルのための条件付きリクエストのコストを払うことになる。このページでは、_headers の 1 ルールで済む修正、その検証方法、コールドキャッシュの訪問を補完する 103 Early Hints、そしてこの問題の発見のきっかけになりがちな Lighthouse の「CSS をインライン化せよ」という提案に、バンドル全体のインライン化で応えるのが誤った対処である理由を扱う。
デフォルト:何も長期キャッシュされない
Workers Static Assets はアセットをこのプラットフォームデフォルトで配信する(リクエストに Authorization や Range ヘッダーがない場合に適用され、レスポンスにはコンテンツ由来の ETag も付く):
cache-control: public, max-age=0, must-revalidateこれはコンテンツハッシュ付きバンドルにも適用される -- styles-<hash>.css、islands-<hash>.js、ビルドがフィンガープリントするものすべてだ。max-age=0, must-revalidate はブラウザに「キャッシュ済みのコピーは即座に古くなる」と伝えるため、ブラウザは手元のコピーを再利用しようとするたび -- 実質的にはナビゲーションのたびに -- まず条件付きリクエスト(If-None-Match: <etag>)を送り、304 Not Modified を受け取る。構造上コンテンツが変わりようのないフィンガープリント付き URL にとって、このラウンドトリップは純粋な無駄だ。
これが見逃されやすいのは、すべてが健全に見えるからだ:
cf-cache-status: HIT-- しかしこれは Cloudflare のエッジキャッシュにファイルがあることしか意味しない。ブラウザが問い合わせなしに手元のコピーを再利用できるかどうかについては何も語らない。304 は安い -- 1 回あたり数十ミリ秒。単体のリクエストは問題に見えないが、このコストはナビゲーションごとの固定費として、アセット数 × ページビュー数の分だけ積み上がる。
エッジキャッシュとブラウザキャッシュは別の問題
cf-cache-status は Cloudflare のエッジとオリジン(ここではアセットストア)の間の経路を表す。cache-control はブラウザとエッジの間の経路を支配する。エッジのヒット率が完璧でも、すべての訪問者にクリックのたびに全アセットの再検証を強いることはあり得る -- それがまさにここで説明したデフォルトの挙動だ。
修正:_headers ルール 1 つ
アセットディレクトリのルートに置く _headers ファイル(Workers Static Assets が Pages と同様にネイティブサポート)に、ハッシュ付きアセットのディレクトリ向けのルールを追加する:
/assets/*
Cache-Control: public, max-age=31536000, immutable パスは自分のビルドがフィンガープリント付き出力を書き出す場所に合わせる(たとえばデフォルト構成の Astro なら /)。配置に関する注意が 2 つ:_headers ファイルはデプロイされるアセットディレクトリの中(ビルド出力)に入っている必要があり、また効果が及ぶのはアセットレイヤーが返すレスポンスだけ -- Worker コードが生成するレスポンスは自前でヘッダーを設定する。
これはフィンガープリント付きビルドにおける古典的な安全キャッシングパターンだ:
ハッシュ付きファイル名はビルドごとに変わる。 新しいデプロイは
styles-<newhash>.cssを生成するため、1 年のmax-ageが古いスタイルを配信することはあり得ない -- 古い URL は単に参照されなくなるだけだ。HTML は
no-cacheのまま。 ページはプラットフォームデフォルトを維持するため、ナビゲーションのたびに新鮮な HTML が取得され、そこには現行のハッシュへの参照が含まれる。immutableはリロード時の再検証まで抑止する。 通常のリロードでは、ブラウザは長いmax-ageがあってもキャッシュ済みアセットを再検証する。immutableは「その必要はない」と伝える。
修正後、再訪時のナビゲーションではハッシュ付きバンドルへのリクエストがゼロになり、CSS/JS はブラウザのディスクまたはメモリキャッシュから直接配信される。
挙動の検証
本番が実際に何を配信しているかを、デプロイの前後で確認する:
# 1. What cache-control does the bundle get?
curl -sv -o /dev/null https://<site>/assets/<bundle> 2>&1 | grep -iE 'cache-control|etag|cf-cache-status'
# 2. Reproduce the 304-per-navigation tax (before the fix):
# take the etag from step 1 and send it back
curl -sv -o /dev/null -H 'If-None-Match: <etag>' https://<site>/assets/<bundle>
# -> HTTP/2 304 before the fix; after the fix the browser never sends this request続いてブラウザでも確認する。DevTools -> Network を開き、ページを読み込んでから 2 ページ目へナビゲートする。修正前は、ハッシュ付きアセットごとにナビゲーションのたびの 304 リクエストが見える。修正後はリクエスト自体がなくなり、"(disk cache)" または "(memory cache)" と表示される。
コールドキャッシュ訪問のための 103 Early Hints
immutable ルールが効くのは再訪だけだ -- キャッシュが空の訪問者は、HTML をダウンロードしてパースした後になってようやく CSS バンドルを発見する。Cloudflare の 103 Early Hints はこのギャップを埋める。本来のレスポンスが生成されている間に、Cloudflare がプリロードヒントを載せた中間レスポンス 103 を送り、ブラウザは HTML の到着前にバンドルの取得を開始できる。
セットアップは 2 つ:
ゾーンで Early Hints を有効化する -- ダッシュボード:Speed -> Settings -> Content Optimization。
クリティカルなアセットのための
Linkヘッダーを出力する。たとえば_headersで:
/
Link: </assets/<bundle>>; rel=preload; as=style 動作の仕組みに注意:Cloudflare はレスポンスから対象となる Link ヘッダーを学習して URL ごとにキャッシュし、その URL への以降のリクエストで 103 中間レスポンスを送出する。Cloudflare がまだ見ていない URL への最初のリクエストにはヒントが付かない -- つまりこれが助けるのは、そのページの Early Hints キャッシュが温まった後の初訪問者であり、最終レスポンスが十分速く到着した場合にはヒント自体がスキップされることもある。
注意点:
ゾーンレベルの設定。 カスタムドメインのゾーンに適用されるため、素の
*.workers.devホストでは効かない。HTTP/2 と HTTP/3 のみ。 ブラウザは HTTP/1.1 上の Early Hints を無視する。
ハッシュ付き URL はデプロイごとに変わる。
Linkヘッダーはフィンガープリント付きファイル名を参照するため、このルールは手書きではなくビルド時に_headersへ注入しなければならない。
CSS 全文インライン化が誤った対処である理由
この一連の調査は、たいてい Lighthouse の提案から始まる:「レンダリングブロックリソースを排除する -- クリティカル CSS のインライン化を検討してください」。共有バンドルに対してこの助言を文字どおり実行すると、MPA はむしろ遅くなる:
インライン化の予算は小さい。 web.dev のガイダンスはクリティカルなファーストビューの CSS のみのインライン化であり、ファーストビューのペイロード全体が最初のラウンドトリップに収まるサイズ -- 目安として圧縮後約 14 KB -- が対象で、バンドル全体ではない。
インライン化はページ横断のキャッシングを破壊する。 外部バンドルは一度ダウンロードされれば全ページで再利用されるが、インライン化された CSS はすべての HTML ドキュメントの中で再ダウンロードされる。共有バンドルを持つマルチページサイト(後述の実例では gzip 後約 39 KB)なら、全ページビューに毎回約 39 KB が上乗せされ続ける。
Lighthouse は別のオーディエンスをモデルにしている。 デフォルトの見積もりは低速 4G(RTT 150 ms)のシミュレーションであり、エッジ配信サイトの実際の訪問者を代表しているとは限らない。バンドルが数十ミリ秒先の Cloudflare エッジから -- さらに上記の修正後は再訪時にブラウザキャッシュから -- 配信されるなら、そのオーディエンスにとってモデル上の削減効果はほぼ消失する。
何が何を解決するのかを明確にしておく:immutable ルールは実際の再訪ナビゲーションを改善するが、Lighthouse の警告は消えない -- Lighthouse は空のキャッシュからのコールドロードを監査するため、ブラウザキャッシュはそこには見えないからだ。監査スコアそのものが重要なら、約 14 KB の予算内で小さなクリティカルサブセットをインライン化するのが正当な手段になる。正当でないのは、コールドロードの指標を満たすためにバンドル全体をインライン化し、実際のナビゲーションすべてを重くすることだ。エッジ配信の MPA において実際の訪問者が体感するのは、_headers の immutable ルールと(必要なら)Early Hints のほうである。
実測データ
takazudomodular.com のケースデータ(2026-07):
約 780 ページの静的 MPA。共有のフィンガープリント付き CSS バンドル 1 つ(素で 269 KB / gzip 後 39 KB)を、本番 Worker 経由の Workers Static Assets で配信。
本番環境はハッシュ付きバンドルを
public, max-age=0, must-revalidate(弱い etag、cf-cache-status: HIT)で配信していた -- サイトの_headersには他のパス向けのルールはあったが、/向けがなかったため、プラットフォームデフォルトが適用されていた。assets/ * 実測したウォーム CDN からの CSS 取得(単一地点・少数サンプルの HTTP/2 経由
curl計測):コネクション確立込みのコールドコネクションで約 60-65 ms、確立済みコネクション上の限界コストで約 25-30 ms。厳密というより目安の数字だが -- これが immutable ルールが取り除く「ナビゲーションごとの 304 税」の規模感であり、全ページビューで全訪問者に課されていた。
この教訓は一般化できる:_headers ファイルが存在しても一部のパスしかカバーしていなければ、残りすべてには黙ってプラットフォームデフォルトが適用される -- 長期キャッシュの恩恵が最も大きいアセットも含めて。