Cache APIとService Workerキャッシュストレージの内部
オフライン対応で「更新が反映されない」「容量超過で消える」が腑に落ちる。Cache Storageのキー照合・vary・部分応答、HTTPキャッシュとの独立性、退避の条件を内部から正確に解きます。
- 1.Cache Storage のキーは Request オブジェクトで、既定では URL(クエリ込み)と method で照合する。Vary 付きレスポンスを保存すると、その指定ヘッダまで一致しないとヒットしないため、ignoreSearch / ignoreMethod / ignoreVary で照合を緩められる。
- 2.Cache Storage は HTTP キャッシュ(max-age や ETag による鮮度判定)とは完全に独立した別レイヤで、Cache-Control を一切見ない。put したものは明示的に delete するまで無期限に残り、鮮度管理はアプリ(Service Worker)の責任になる。
- 3.Cache Storage は IndexedDB 等と同じオリジン共有のクォータを使い、空き逼迫時はオリジン単位(best-effort なら LRU 的)に丸ごと退避され得る。persist() で永続化を要求でき、Range リクエストの部分応答(206)はそのままでは扱いに注意が要る。
caches.open(name) で得た Cache に put するだけ、と思われがちな Cache API には、「クエリ違いでヒットしない」「Vary のせいで取れない」「Cache-Control を付けたのに古いまま」「いつの間にか全部消えた」という事故の種が詰まっています。これらはすべて Cache Storage のキー照合アルゴリズムと、HTTP キャッシュからの独立性、そしてオリジン共有クォータの退避規則を知れば説明がつきます。ここではキーである Request の照合、Vary と部分応答、HTTP キャッシュとの違い、容量管理と退避を内部動作から見ていきます。
Cache Storage のデータモデル
Cache Storage は「名前付き Cache の集合」です。caches(CacheStorage)の下に複数の Cache がぶら下がり、各 Cache は Request をキー、Response を値とするエントリの列を保持します。素朴な Map と違うのは、キーが文字列ではなく Request オブジェクトであり、照合が後述の専用アルゴリズムで行われる点です。
const cache = await caches.open('static-v3'); // 名前付き Cache を取得 or 新規作成
await cache.put(new Request('/app.js'), response); // Request→Response を 1 件保存
const hit = await cache.match('/app.js'); // 照合してヒットすれば Response
重要なのは、保存される Response がボディまで含めた完全なクローンだという点です。put はレスポンスボディの ReadableStream を読み切ってストレージに焼き付けるため、put 後に元の Response 本文を再度読もうとすると失敗します。Fetch API の内部 で見たボディ一度きりの制約がそのまま効くので、レスポンスを「キャッシュにも入れつつアプリでも使う」場合は put の前に clone() しておきます。
cache.put は実はかなり寛容で、opaque レスポンス(no-cors の戻り値, status 0)や 200 番台でない通常レスポンスも受け付けます。put が TypeError で拒否するのは主に、(1) URL スキームが http/https でない、(2) レスポンスのステータスが 206 Partial Content、(3) レスポンスに Vary: * ヘッダがある、の 3 ケースです。逆に厳しいのは add/addAll のほうで、こちらは内部の fetch がステータスを検査するため、200 番台でないレスポンスや opaque レスポンスは保存できません。opaque を保存したいときは put を使います。なお 206 は put 自体が拒否されるため、後述のとおりフル(200)で保存して自前で範囲合成するのが定番です。
キーは Request:照合アルゴリズム
match(request) の核心は、保存済みエントリの中から「同じリクエスト」を見つける照合です。既定の判定軸は次の通りです。
| 照合軸 | 既定の挙動 | 緩めるオプション |
|---|---|---|
| URL(クエリ含む) | 完全一致が必要(?a=1 と ?a=2 は別物) | ignoreSearch: true でクエリ無視 |
| method | GET 同士で一致(HEAD は GET 扱いで照合可) | ignoreMethod: true で method 無視 |
| Vary 指定ヘッダ | 保存時 Vary のヘッダ値まで一致が必要 | ignoreVary: true で Vary 無視 |
つまり、文字列として渡した URL は内部で Request 化され、URL は Fragment(#...)を除いてフルに比較されます。クエリ文字列も比較対象なので、/api?page=1 で保存したエントリは /api?page=2 の match にはヒットしません。クエリをキャッシュバスティングやトラッキングにしか使っていないなら、{ ignoreSearch: true } でクエリを無視して照合できます。
// /img/logo.png?v=hash で保存していても、バージョン無視で取りたい
const hit = await cache.match('/img/logo.png', { ignoreSearch: true });
method について、Cache Storage は安全な取得である GET(と HEAD)だけをキー対象とし、POST などボディを持つ非冪等メソッドは原則キーにできません。ignoreMethod: true は照合時に method の一致を要求しなくするオプションで、保存時の制約をなくすものではない点に注意します。
Vary:レスポンス側が照合条件を増やす
match がややこしくなる最大の要因が Vary レスポンスヘッダです。サーバーが Vary: Accept-Encoding のように返すと、「このレスポンスは指定ヘッダの値ごとに内容が変わる」という宣言になります。Cache Storage はこれを尊重し、保存時のリクエストの当該ヘッダ値と、match 時のリクエストの当該ヘッダ値が一致しなければヒットさせません。
たとえば Vary: Accept-Language 付きで日本語版を保存した場合、Accept-Language: ja のリクエストでは取れますが、Accept-Language: en のリクエストでは同じ URL でもミスします。これは正しい挙動で、言語が違う内容を誤配信しないための安全装置です。
* は「あらゆる要因で変わりうる」を意味し、キャッシュにとっては実質「再利用不能」の宣言です。Cache API はこれを反映し、Vary: * を含むレスポンスは put した時点で TypeError になり、そもそも保存できません(仕様上の拒否条件)。意図せず Vary: * を返している API をキャッシュしたいときは、保存するレスポンスから Vary を除いた新 Response を作って put します。なお Vary: Accept-Language のような具体的なヘッダ指定の Vary は保存自体は通り、その場合は照合時のヘッダ不一致でミスするので { ignoreVary: true } で緩められます。
// Vary を無視して URL+method だけで照合したい
const hit = await cache.match(req, { ignoreVary: true });
// あるいは保存時に Vary を落とした Response を作る(ヘッダは新インスタンスで)
const headers = new Headers(response.headers);
headers.delete('Vary');
const stripped = new Response(response.body, { status: response.status, headers });
await cache.put(req, stripped);
HTTP キャッシュとは完全に独立
最も誤解が多いのが、Cache Storage と HTTP キャッシュ(ブラウザの自動キャッシュ)の関係です。結論から言うと、両者は無関係な別レイヤです。
| 観点 | HTTP キャッシュ | Cache Storage(Cache API) |
|---|---|---|
| 管理主体 | ブラウザが自動 | アプリ(Service Worker / JS)が明示操作 |
| 鮮度判定 | Cache-Control / Expires / ETag で自動 | なし(無期限に保持) |
| 再検証 | If-None-Match などで自動 | アプリが自前で実装 |
| キー | URL + Vary(実装依存の正規化) | Request(上記アルゴリズム) |
| 失効 | max-age 経過で stale | delete するまで残る |
Cache Storage は put したレスポンスの Cache-Control も Expires も ETag も一切見ません。max-age=60 を付けても 60 秒で消えたりはせず、明示的に delete するまで無期限に残ります。鮮度の概念そのものが Cache API には存在しないので、「古くなったら再取得する」というロジックは Service Worker 側で自分で書く必要があります。これが Service Worker のキャッシュ戦略(cache-first / network-first / stale-while-revalidate など)が必要になる理由です。各戦略の中身は Service Worker のライフサイクルとキャッシュ戦略 に譲りますが、いずれも「Cache Storage は勝手に古くならない」前提に立っています。
一方で、cache.add/addAll や Service Worker 内の fetch がネットワークへ出る瞬間には、ブラウザの HTTP キャッシュが間に挟まり得ます。つまり「Cache Storage に入れるためのフェッチ」が HTTP キャッシュから古い応答を拾ってしまうことがあるため、確実に新鮮な版を焼きたいときは fetch(req, { cache: 'reload' }) で HTTP キャッシュを迂回します。HTTP キャッシュ側の鮮度判定そのものは HTTP キャッシュ の領分です。
頻出の引っかけは「Cache-Control: max-age を付ければ Cache Storage のエントリも自動失効する」という誤り。Cache Storage は HTTP のキャッシュ制御ヘッダを評価しないため、失効はアプリが delete/上書き put で行う。鮮度管理が要るなら戦略(SWR 等)を自分で実装する、と覚えます。
部分応答(Range)の扱い
動画や音声、大きな PDF で使われる Range リクエスト(Range: bytes=... → 206 Partial Content)は、Cache API では特に注意が要ります。そもそも**206 レスポンスはそのままでは put できず TypeError になります**(Cache API は仕様上 206 の保存を許しません)。仮に保存できたとしても、match は基本的に「リクエストと保存エントリの対応」で照合するだけで、HTTP キャッシュのようにバイト範囲を自動で切り出して 206 を合成する仕組みは持ちません。したがって Range を扱うメディアでは、フルボディを取得して保存し直す前処理が必要になります。
実務では、Service Worker で fetch を横取りしてキャッシュ済みのフル(200)レスポンスから要求バイト範囲を自前で切り出して 206 を組み立てて返す実装パターンが定番です。Range ヘッダをパースし、ArrayBuffer の該当区間を slice し、Content-Range/Content-Length を付けた 206 を返す、という手順になります。Cache API はあくまでバイト列の保管庫であり、範囲合成はアプリの仕事だと理解しておくと迷いません。
容量管理と退避
Cache Storage は無限ではありません。オリジン単位の共有クォータを、IndexedDB やストレージのトランザクションモデル で扱う IndexedDB・Cache Storage・Service Worker 登録などと同じバジェットから消費します。クォータはオリジンごとに割り当てられ、navigator.storage.estimate() で概算(usage と quota)を取得できます。
const { usage, quota } = await navigator.storage.estimate();
// usage: 現在の使用バイト(概算), quota: 上限の目安
ディスクの空きが逼迫すると、ブラウザは容量を回収するためにオリジン単位でストレージを丸ごと退避(eviction)します。ここで効くのが永続性(persistence)の区分です。
| 区分 | 退避の対象 | 典型条件 |
|---|---|---|
| best-effort(既定) | 退避され得る(多くは LRU 的に古いオリジンから) | ユーザー操作の少ないサイト |
| persistent | 原則退避されない(ユーザー削除のみ) | persist() が許可されたサイト |
既定の best-effort ストレージは、空き不足時にブラウザの裁量で退避され得ます。退避は個別エントリ単位ではなくオリジン単位で丸ごと起きるのが要点で、「一部だけ消える」のではなく「そのオリジンの Cache Storage と IndexedDB がまとめて消える」挙動になります。退避順は実装依存ですが、概ね最近使われていないオリジンが先に回収されます(LRU 的)。
これを避けたいオフラインアプリは、navigator.storage.persist() で永続化を要求します。許可されれば persistent 区分になり、空き逼迫でも自動退避の対象外になります(ユーザーが手動で消すか、ブラウザが明示的に許可を取り消した場合のみ消える)。許可されるかはブラウザのヒューリスティック(インストール済み PWA か、エンゲージメントが高いか等)次第で、要求が通らないこともあります。
const persisted = await navigator.storage.persist(); // true なら永続化された
const already = await navigator.storage.persisted(); // 現状の永続状態を確認
「Cache Storage に入れたから絶対にオフラインで動く」は best-effort では保証されません。クォータ逼迫でオリジンごと飛ぶ可能性があるため、確実なオフライン体験が要るなら persist() を要求し、さらに起動時にキャッシュの存在を検証して欠けていれば再フェッチする防御的な実装にします。退避は静かに起きるので、estimate() で使用量を監視し、不要な版(古い static-vN)は caches.delete(name) で能動的に整理します。
バージョニングと一括クリーンアップ
Cache が無期限保持であることの裏返しとして、古いキャッシュの掃除はアプリの責任です。定番は「Cache 名にバージョンを埋め込み、activate 時に旧バージョンを一掃する」パターンです。これにより、デプロイのたびに match が新版を引き、旧版はまとめて解放されます。
const CACHE = 'static-v3';
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
const names = await caches.keys(); // 全 Cache 名
await Promise.all(
names.filter((n) => n !== CACHE) // 現行以外を
.map((n) => caches.delete(n)) // まとめて削除
);
})());
});
まとめ
Cache Storage は Request をキー、Response(ボディ込みクローン)を値とする名前付きストアで、照合は URL(クエリ含む・Fragment 除く)+ method + Vary 指定ヘッダの一致で行い、ignoreSearch/ignoreMethod/ignoreVary で緩められます。Vary: * や 206 を含むレスポンスは put 自体が TypeError で拒否される点に注意します。Cache Storage は HTTP キャッシュとは完全に独立で、Cache-Control 等を評価せず delete するまで無期限保持するため、鮮度管理(cache-first / SWR 等)は Service Worker 側で自前実装が必要です。新鮮な版を焼くフェッチは HTTP キャッシュ を cache: 'reload' で迂回します。Range の 206 は自動範囲合成がないため自前切り出しが定番です。容量は オリジン共有クォータ(IndexedDB 等 と同バジェット)から消費し、空き逼迫時は best-effort なオリジンが丸ごと退避され得るので、確実なオフラインには persist() 要求と、起動時の存在検証・旧版の能動削除を組み合わせます。
Web/フロントエンド Article
Cache APIとService Workerキャッシュストレージの内部を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Cache API
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
Cache Storage は HTTP キャッシュ(max-age や ETag による鮮度判定)とは完全に独立した別レイヤで、Cache-Control を一切見ない。put したものは明示的に delete するまで無期限に残り、鮮度管理はアプリ(Service Worker)の責任になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「Cache API / Service Worker」に近いか確認する。
- 強みである「Cache Storage のキーは Request オブジェクトで、既定では URL(クエリ込み)と method で照合する。Vary 付きレスポンスを保存すると、その指定ヘッダまで一致しないとヒットしないため、ignoreSearch / ignoreMethod / ignoreVary で照合を緩められる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。