TL

ブラウザキャッシュの階層と判定アルゴリズム

なぜ更新が反映されないのかを根本から解決。メモリ/ディスク/HTTP/Service Worker の優先順、Cache-Control と ETag の再検証フロー、キャッシュキー分割まで内部動作を正確に整理します。

応用ブラウザキャッシュHTTPService Workerパフォーマンス最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.ブラウザのキャッシュは単層ではなく、Service Worker・メモリ・ディスク・HTTP の複数層を所定の順で照会する。
  • 2.鮮度判定は Cache-Control の max-age(または Expires)で行い、期限切れ後は ETag / Last-Modified を使った条件付きリクエストで 304 を狙う。
  • 3.格納先は URL だけでなく、メソッド・Vary 対象ヘッダ・パーティション(cache partitioning)を合わせたキャッシュキーで分割される。

キャッシュは単層ではない

「ブラウザキャッシュ」と一括りに呼びますが、実際には役割の異なる複数の層が重なっています。リクエストが発生したとき、ブラウザはこれらを決まった順序で照会し、最初にヒットした層の応答を採用します。順序を誤解すると「Cache-Control: no-store を付けたのに戻るボタンで古い画面が出る」「Service Worker を更新したのに反映されない」といった現象の理由が見えません。

主要な層を、照会される側(上)から並べると次のとおりです。

実体永続性制御主体
Service Worker / Cache Storagefetch を横取りする JS とその保管庫明示削除まで永続アプリのコード
Memory Cacheプロセス内メモリ上の応答そのタブ/ドキュメントの寿命のみブラウザ内部(指定不可)
Disk Cache(HTTP Cache)ディスク上の HTTP 応答キャッシュmax-age と容量で寿命管理Cache-Control / ETag
Push Cache / PrefetchHTTP/2 push・投機的取得の一時置場数十秒〜接続単位と短命サーバー/rel=prefetch

このうち、開発者が HTTP ヘッダで直接制御できるのは主に Disk Cache(HTTP Cache)です。Memory Cache はブラウザ内部の最適化で、指定はできません。Service Worker だけは別格で、ネットワーク層より手前でリクエストを横取りするため、最優先で応答を差し替えられます。

照会の優先順位

ナビゲーションやサブリソース取得で、ブラウザがキャッシュを引く順序は概ね次のようになります。これは「アルゴリズム」と呼べる固定の判定フローです。

リクエスト発生
  │
  ├─[1] Service Worker は登録済みで fetch を処理するか?
  │       Yes → SW の fetch ハンドラへ(Cache Storage や独自ロジックで応答可)
  │              ※ respondWith しなければ [2] 以降へ素通し
  │
  ├─[2] Memory Cache にこのキーの新鮮な応答があるか?
  │       Yes → 即返す(ネットワークも検証も行わない)
  │
  ├─[3] Disk Cache(HTTP Cache)にエントリがあるか?
  │       新鮮(max-age 内)        → そのまま返す
  │       期限切れ+検証子あり       → 条件付きリクエストで再検証(304 を狙う)
  │       無い/no-store           → [4] へ
  │
  └─[4] ネットワークへ取得(必要なら Push/Prefetch のキャッシュを消費)

重要なのは、Service Worker と Memory Cache は HTTP の鮮度判定より手前で勝つ点です。たとえば Service Worker が caches.match でヒットを返せば、Cache-Control: no-store を付けていてもサーバーには一切問い合わせが行きません。no-store が効くのは Disk Cache(HTTP Cache)に対してであり、Service Worker の Cache Storage は別の保管庫だからです。

no-store でも戻る/進むで古い画面が出る理由

履歴ナビゲーション(戻る・進む)には bfcache(Back/Forward Cache)という別機構があり、DOM や JS の状態ごとページを丸ごと凍結保存します。これは HTTP キャッシュとは独立で、Cache-Control: no-store を付けたページはモダンブラウザでは bfcache 対象外になりますが、no-cache だけでは凍結されることがあります。機密ページは no-store を使い、unload ではなく pagehide を前提に設計してください。

鮮度判定(max-age と Expires)

Disk Cache のエントリを「再利用してよいか」を決めるのが**鮮度(freshness)**の計算です。ブラウザは次の優先順で有効期限を求めます。

  1. Cache-Control: max-age=N(秒)があれば最優先。s-maxage は共有キャッシュ向けで、ブラウザは無視。
  2. 無ければ Expires(絶対時刻)を見る。
  3. どちらも無ければ Last-Modified から経験則で推定する(heuristic freshness、概ね経過時間の 10% 程度)。

実際に鮮度が残っているかは、応答の**経過時間(age)**と比較して判定します。age は応答の DateAge ヘッダから算出され、判定式は次のとおりです。

新鮮と見なせる条件:  age < freshness_lifetime
  freshness_lifetime = max-age があれば max-age
                       なければ Expires - Date
                       なければ (Date - Last-Modified) * 0.1 など

agefreshness_lifetime 未満ならネットワークに触れず即返却(HTTP Cache のヒット)。超えていれば「古い(stale)」と見なし、再検証フェーズへ進みます。immutable が付いていれば、有効期間中は再検証すら省略され、リロード時もサーバーに問い合わせません。

再検証フロー(ETag と 304)

鮮度が切れても、中身が変わっていなければ本文の再取得は無駄です。そこで変更の有無だけを問い合わせるのが条件付きリクエスト(再検証)です。サーバーが付けた検証子をブラウザが送り返します。

サーバーが付ける検証子ブラウザが送る条件ヘッダ判定の精度
ETag(内容の指紋)If-None-Match: 「ETag値」高い。バイト単位の同一性を表せる
Last-Modified(更新時刻)If-Modified-Since: 「時刻」秒単位。1秒未満の更新は取りこぼす

両方ある場合、ブラウザは If-None-Match(ETag)を優先します。サーバー側の判定は単純です。検証子が一致すれば本文を送らず 304 Not Modified を返し、ブラウザは手元のコピーを再利用します。変わっていれば 200 OK で新しい本文と新しい検証子を返します。

# 1回目の応答(サーバー → ブラウザ)
HTTP/1.1 200 OK
Cache-Control: max-age=600
ETag: "v3-9f3c2a"

# max-age 経過後の再検証(ブラウザ → サーバー)
GET /app.css HTTP/1.1
If-None-Match: "v3-9f3c2a"

# 変更なし:本文ゼロで「そのまま使え」
HTTP/1.1 304 Not Modified
ETag: "v3-9f3c2a"

ETag には強い検証子"abc")と弱い検証子W/"abc")があります。弱い検証子は「意味的に等価」を示すだけで、Range リクエスト(部分取得)には使えません。圧縮の有無やプロキシ経由で本文がバイト単位で変わる場合は、強い ETag が壊れて常に 200 を返してしまうことがあるため、配信経路の一貫性が前提になります。

304 はキャッシュミスではない

304 Not Modified は「キャッシュが効かなかった」ではなく「再検証に成功し、本文転送を省けた」状態です。ネットワーク往復は1回発生しますが、本文(多くの場合データ量の大半)は転送されません。「ヒット率」を語るときは、無検証ヒットと 304 再検証ヒットを分けて数えると、実際の転送削減量を正しく評価できます。

キャッシュキーの分割

ブラウザはキャッシュエントリを URL 単独ではなく、複数の要素を組み合わせたキャッシュキーで管理します。ここを理解しないと「同じ URL なのに別のキャッシュが引かれる/引かれない」が説明できません。キーを構成する主な要素は次のとおりです。

  • メソッドと URLGET /aPOST /a は別エントリ。クエリ文字列も URL の一部としてキーに含まれる。
  • Vary 対象ヘッダ:応答に Vary: Accept-Encoding があれば、リクエストの Accept-Encoding 値ごとに別エントリになる。Vary: * は事実上キャッシュ不可。
  • キャッシュパーティション:近年のブラウザは、トラッキング対策として最上位サイト(top-level site)でキャッシュを分割する。同じ CDN 上の script.js でも、埋め込まれた親サイトが異なれば別キーになり、サイト横断の使い回しは起きない。
キャッシュキー ≒ ( パーティション(最上位サイト),
                  メソッド,
                  URL(クエリ含む),
                  Vary 指定ヘッダの値 )

パーティション分割(cache partitioning)は、かつて成立した「人気 CDN のライブラリは他サイトで取得済みだから即ヒット」という前提を無効化しました。プライバシー上の前進ですが、共有 CDN によるキャッシュ温存効果は失われています。配信最適化はサイト単位で考える必要があります。

Vary は最小限に絞る

Vary: User-Agent のように値の種類が膨大なヘッダを指定すると、わずかな差異ごとにキャッシュが分裂し、ヒット率が壊滅します。内容を本当に切り替える必要があるヘッダ(多くは Accept-Encoding 程度)だけに絞るのが鉄則です。コンテンツネゴシエーションは可能なら URL の分離で表現する方が、キャッシュ効率の点で有利です。

層をまたぐ更新戦略

複数層が重なる以上、「更新を確実に届ける」には各層を整合させる設計が要ります。実務での定石をまとめます。

  • 静的アセット:ファイル名にハッシュを埋め込み(app.9f3c2a.js)、max-age=31536000, immutable。内容が変われば URL ごと変わるため、再検証も不要で確実に切り替わる。
  • HTML(エントリポイント)no-cache(保存はするが毎回再検証)。ここが最新であれば、参照する新しいアセット名へ自然に追従する。
  • Service Worker:登録スクリプト自体は max-age を短く(または no-cache)。Cache Storage はバージョン付きの名前で世代管理し、activate で旧世代を削除する。skipWaiting の扱いはタブ間の整合性とトレードオフ。

これらは HTTP キャッシュ のヘッダ設計と地続きで、Service Worker 側の挙動は PWA とサービスワーカー で詳述しています。クライアント側の保管先全般の使い分けは Web ストレージ、表示速度への寄与は Web パフォーマンス も合わせて読むと、層の全体像がつながります。

Web/フロントエンド Article

ブラウザキャッシュの階層と判定アルゴリズムを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

ブラウザ

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

鮮度判定は Cache-Control の max-age(または Expires)で行い、期限切れ後は ETag / Last-Modified を使った条件付きリクエストで 304 を狙う。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「ブラウザ / キャッシュ」に近いか確認する。
  • 強みである「ブラウザのキャッシュは単層ではなく、Service Worker・メモリ・ディスク・HTTP の複数層を所定の順で照会する。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

ブラウザキャッシュHTTPService WorkerパフォーマンスブラウザキャッシュHTTP
参考: 公式情報