ブラウザキャッシュの階層と判定アルゴリズム
なぜ更新が反映されないのかを根本から解決。メモリ/ディスク/HTTP/Service Worker の優先順、Cache-Control と ETag の再検証フロー、キャッシュキー分割まで内部動作を正確に整理します。
- 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 Storage | fetch を横取りする JS とその保管庫 | 明示削除まで永続 | アプリのコード |
| Memory Cache | プロセス内メモリ上の応答 | そのタブ/ドキュメントの寿命のみ | ブラウザ内部(指定不可) |
| Disk Cache(HTTP Cache) | ディスク上の HTTP 応答キャッシュ | max-age と容量で寿命管理 | Cache-Control / ETag |
| Push Cache / Prefetch | HTTP/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 は別の保管庫だからです。
履歴ナビゲーション(戻る・進む)には bfcache(Back/Forward Cache)という別機構があり、DOM や JS の状態ごとページを丸ごと凍結保存します。これは HTTP キャッシュとは独立で、Cache-Control: no-store を付けたページはモダンブラウザでは bfcache 対象外になりますが、no-cache だけでは凍結されることがあります。機密ページは no-store を使い、unload ではなく pagehide を前提に設計してください。
鮮度判定(max-age と Expires)
Disk Cache のエントリを「再利用してよいか」を決めるのが**鮮度(freshness)**の計算です。ブラウザは次の優先順で有効期限を求めます。
Cache-Control: max-age=N(秒)があれば最優先。s-maxageは共有キャッシュ向けで、ブラウザは無視。- 無ければ
Expires(絶対時刻)を見る。 - どちらも無ければ
Last-Modifiedから経験則で推定する(heuristic freshness、概ね経過時間の 10% 程度)。
実際に鮮度が残っているかは、応答の**経過時間(age)**と比較して判定します。age は応答の Date や Age ヘッダから算出され、判定式は次のとおりです。
新鮮と見なせる条件: age < freshness_lifetime
freshness_lifetime = max-age があれば max-age
なければ Expires - Date
なければ (Date - Last-Modified) * 0.1 など
age が freshness_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 Not Modified は「キャッシュが効かなかった」ではなく「再検証に成功し、本文転送を省けた」状態です。ネットワーク往復は1回発生しますが、本文(多くの場合データ量の大半)は転送されません。「ヒット率」を語るときは、無検証ヒットと 304 再検証ヒットを分けて数えると、実際の転送削減量を正しく評価できます。
キャッシュキーの分割
ブラウザはキャッシュエントリを URL 単独ではなく、複数の要素を組み合わせたキャッシュキーで管理します。ここを理解しないと「同じ URL なのに別のキャッシュが引かれる/引かれない」が説明できません。キーを構成する主な要素は次のとおりです。
- メソッドと URL:
GET /aとPOST /aは別エントリ。クエリ文字列も URL の一部としてキーに含まれる。 - Vary 対象ヘッダ:応答に
Vary: Accept-Encodingがあれば、リクエストのAccept-Encoding値ごとに別エントリになる。Vary: *は事実上キャッシュ不可。 - キャッシュパーティション:近年のブラウザは、トラッキング対策として最上位サイト(top-level site)でキャッシュを分割する。同じ CDN 上の
script.jsでも、埋め込まれた親サイトが異なれば別キーになり、サイト横断の使い回しは起きない。
キャッシュキー ≒ ( パーティション(最上位サイト),
メソッド,
URL(クエリ含む),
Vary 指定ヘッダの値 )
パーティション分割(cache partitioning)は、かつて成立した「人気 CDN のライブラリは他サイトで取得済みだから即ヒット」という前提を無効化しました。プライバシー上の前進ですが、共有 CDN によるキャッシュ温存効果は失われています。配信最適化はサイト単位で考える必要があります。
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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。