メインコンテンツまでスキップ

IndexedDB と Cache API — 大きいデータと、消える条件

前の章の 3 つの機構は、どれも大きいデータに向きませんでした。Web Storage はオリジンごとの領域全体で 5 MiB が上限で、API も同期です。クッキーの制限はさらに小さく、しかもリクエストに自動で付きます。

この章が扱うのは、その先にある 2 つの仕組みです。ただし容量の話だけではありません。ここでも境界がずれています。 仕様が定める分け方と実装が実際に使う分け方が一致せず、「保存した」ことと「残っている」ことも一致しません。

この章で学ぶこと

  • 仕様が storage key をオリジンの組と定める一方、実装は埋め込み先のサイトも鍵に含めていること
  • 「保存した」と「残っている」が一致しない理由と、navigator.storage.persist() が変えるもの
  • IndexedDB が非同期かつトランザクショナルであること
  • Cache API が RequestResponse の組を持つこと
前提知識

ブラウザの保存領域 の 5 軸を前提にします。Cache API は HTTP キャッシュと CDN の考え方を、非同期 API の扱いは イベントループ を前提にします。

この章で扱わないこと

観点参照先
localStorage / sessionStorage / クッキーの選び分けブラウザの保存領域
サーバーが指示するキャッシュ (Cache-Control / ETag)HTTP キャッシュと CDN
非同期処理の実行順とマイクロタスクイベントループ
クッキーの Partitioned 属性クッキー

仕様の境界と、実装の境界がずれている

Storage Standard は保存領域を分ける鍵 (storage key) を「a tuple consisting of an origin」と定めます。オリジンの組、つまり scheme + host + port です。

ところが同じ仕様が、その定義のすぐそばに注記を置いています。

This is expected to change; see Client-Side Storage Partitioning

仕様自身が「これは変わる見込みだ」と書いているわけです。そして実装は先に進んでいます。

実装はオリジンだけを鍵にしていない

3 つのブラウザエンジンは、いずれも第三者文脈 (他のサイトに埋め込まれた iframe など) の保存領域を埋め込み先のサイトごとに分けます。

実装分け方
Firefox「double-keys all client-side state by the origin of the resource being loaded and by the top-level site」
Chromium「that data is now isolated and only available to contexts that share both the same origin and the same top-level site」。Chrome 115 以降で全ユーザーに有効
WebKit「Third-party LocalStorage and IndexedDB are partitioned per first-party website and also made ephemeral」

つまり widget.examplea.comb.com の両方に埋め込まれているとき、両者の保存領域は別々になります。埋め込み元から見れば同じオリジンなのに、中身が共有されません。

a.com に埋め込まれた widget.example → 領域 1
b.com に埋め込まれた widget.example → 領域 2
widget.example を直接開いたとき → 領域 3

サイトをまたいだ追跡を防ぐための分割です。同じスクリプトが複数のサイトに埋め込まれていても、訪問履歴をつなげられなくなります。

実装ごとの差も残っている

一致しているのは「埋め込み先のサイトを鍵に含める」ところまでで、細部は揃っていません。

内容
永続性WebKit は第三者文脈の保存領域をディスクに書きません (ephemeral)。ブラウザを終了すると消えます
入れ子の扱いChromium は鍵に ancestor bit を足します。途中の iframe に別サイトのものが挟まっていれば立ちます
クッキーの扱いFirefox はクッキーを動的に分割します。Storage Access API で分割前の領域へアクセスを求められます

「主要ブラウザは分割する」までは共通の前提にしてよく、そこから先は実装を確かめる必要があります。 サードパーティのウィジェットを配る側は、第三者文脈で保存が効かない前提を置くのが安全です。

26 章の Partitioned 属性はクッキー側の同じ話です。クッキーには属性で明示する経路があり、Web Storage と IndexedDB には無く実装が黙って分けます。

「保存した」と「残っている」は一致しない

書き込みが成功しても、後で読めるとは限りません。ブラウザは保存領域が逼迫したとき、データを捨てます。

best-effort と persistent

既定の扱いは best-effort です。名前のとおり最善は尽くしますが、保証はありません。

モード退避の対象になるか
best-effort (既定)なる
persistentならない

退避の順序は最終アクセスが古いものからです。navigator.storage.persist() を呼んで許可を得たオリジンは、この対象から外れます。

const persisted = await navigator.storage.persist();

戻り値が true なら persistent になっています。許可の判断はブラウザが行うので、呼べば必ず通るわけではありません。ホーム画面に追加されている、ブックマークされている、訪問頻度が高いといった条件を見る実装があります。

使われていないと消える実装もある

Safari は、サイト越えの追跡防止が有効なとき、ブラウザを使った日で数えて 7 日間ユーザーの操作が無かったオリジンの、スクリプト由来のデータを削除します。これは容量の逼迫とは無関係で、時間だけで発火します。サーバーが設定したクッキーは対象外です。

訪問の間隔が空くサービスでは、オフライン用に貯めたデータが毎回消えている可能性があります。「開発中は再現しなかったのに本番で報告が上がる」型の症状になります。

設計に落とすと

ブラウザの保存領域を唯一の保管場所にしないことです。消えても復旧できる形にします。

  • サーバーにあるデータの写しとして使う
  • 消えていたら取り直す経路を持つ
  • 消えて困るものは persistent を要求し、戻り値を確かめる

IndexedDB — 非同期でトランザクショナル

MDN は IndexedDB を「a transactional database system, like an SQL-based RDBMS」と表現し、操作は「done asynchronously, so as not to block applications」と述べています。

Web Storage との違いは 2 つです。

観点Web StorageIndexedDB
API同期非同期
一貫性の単位なし (1 操作ずつ)トランザクション
値の型文字列のみ構造化複製できるもの (オブジェクト / Blob / ファイル)

同期 API の localStorage は、読み書きのあいだメインスレッドを止めます。5 MiB の上限に収まる範囲でも、大きい値を扱えば描画と操作が止まります。IndexedDB は非同期なのでこれが起きません。

トランザクションがあるので、複数のレコードをまとめて書くときに途中で失敗しても半端な状態が残りません。生の API はイベントベースで書きづらいので、実務では Promise でくるむ薄いライブラリを挟みます。次はその形です。

const tx = db.transaction('drafts', 'readwrite');
await tx.objectStore('drafts').put({ id: 1, body: '...' });
await tx.done;

同一オリジンポリシーに従うので、別のオリジンからは読めません。ただし前半で見たとおり、第三者文脈では埋め込み先のサイトによってさらに分かれます。

Cache API — リクエストとレスポンスの組

Cache API が持つのは RequestResponse の組です。キーがリクエスト、値がレスポンスになります。

const cache = await caches.open('assets-v1');
await cache.add('/styles/main.css');
const hit = await cache.match('/styles/main.css');

MDN は「An origin can have multiple, named Cache objects」「they don't expire unless deleted」と述べています。名前を付けた領域を複数持てて、明示的に消さない限り期限で消えません

ここが HTTP キャッシュ との違いです。Cache-Control はサーバーが有効期間を指示し、ブラウザが自動で判断します。Cache API はアプリケーションのコードが入れて、消すまで残ります。バージョン付きの名前 (assets-v1) を使い、新しい版を入れたら古い領域を消す運用になります。

Service Worker との関係

Cache API は Service Worker とセットで語られますが、Service Worker 専用ではありません。ページのスクリプトからも caches を使えます。

Service Worker はページとネットワークのあいだに入るスクリプトです。ページが出した fetch を横取りして、キャッシュから返すか、ネットワークへ通すかを決められます。

ページ → [Service Worker] → ネットワーク

Cache API

この位置にいるので、オフラインでも応答を返せます。ネットワークが死んでいてもキャッシュから返せば、アプリケーションは動き続けます。

Service Worker はオリジンごとに登録され、ページより長く生き残ります。第三者文脈での分割の対象にもなっていて、Firefox の一覧にも WebKit の記述にも含まれます。

どれだけ使えるかを測る

Storage Standard の登録済み保存先の表は、localStoragesessionStorage に 5 MiB を割り当てる一方、cachesindexedDB には値を割り当てていません。

これを「上限なし」と読まないでください。 割り当てが無いのはその保存先ごとの制限であって、オリジン全体にはブラウザが決めた quota がかかります。

実装best-effort での上限の目安
Firefoxディスクの 10% と 10 GiB のうち小さいほう。10 GiB は同じサイトに属するオリジン全体にかかる
Chromiumディスクの 60%
WebKit約 60%。web content を埋め込む別のアプリでは約 15%

クッキーの 4096 バイトと同じ構図です。仕様が書く値と実装がかける制限は別のもので、どちらか片方だけを見ると足をすくわれます。

実際に使える量は実行時に測れます。

const { quota, usage } = await navigator.storage.estimate();

返る値は概算です。追跡を防ぐためにブラウザが丸めることがあるので、境界ぎりぎりの判定には使えません。

よくある誤解

「IndexedDB と Cache API には容量の上限がない」 — 仕様が保存先ごとの値を割り当てていないだけです。オリジン全体にはブラウザの quota がかかり、実装ごとに算出方法が違います。

「保存に成功したら残っている」 — 既定は best-effort で、退避の対象です。navigator.storage.persist() の許可を得るまで保証はありません。

persist() を呼べば永続化される」 — 呼べるだけで、許可するかはブラウザが決めます。戻り値を確かめる必要があります。

「保存領域はオリジンで分かれる」 — 仕様はそう定めますが、仕様自身が変わる見込みだと注記しており、3 つの実装はいずれも第三者文脈で埋め込み先のサイトも鍵にしています。

「Cache API は Service Worker がないと使えない」 — ページのスクリプトからも caches を使えます。Service Worker が要るのは fetch を横取りする部分です。

確認問題

問 1. 分析用のウィジェットを配っています。widget.example の iframe が a.comb.com に埋め込まれ、IndexedDB に訪問者の識別子を書きます。a.com で書いた識別子は b.com で読めますか。

解答と解説

答え: 読めません。

3 つの実装はいずれも、第三者文脈の保存領域を埋め込み先のトップレベルサイトごとに分けます。widget.example から見れば同じオリジンですが、a.com の中と b.com の中では別の領域になります。

そもそもこの分割は、いま挙げたようなサイトをまたいだ追跡を成立させないために入りました。意図どおりに効いている状態です。

WebKit ではさらに、第三者文脈の保存領域がディスクに書かれません。ブラウザを終了した時点で消えます。

サードパーティのウィジェットを配る側は、第三者文脈では保存が効かない前提で設計します。識別が必要なら、埋め込み先から明示的に渡してもらう経路を作ります。

問 2. オフライン対応のメモアプリで、下書きを IndexedDB に保存しています。「保存しました」と表示した後、翌週に開くとデータが消えているという報告がありました。実装のバグを疑う前に何を確認しますか。

解答と解説

答え: 保存領域が persistent になっているかと、報告された環境のブラウザを確認します。

書き込みが成功していても、既定の best-effort では退避の対象です。確認する点は 2 つあります。

確認内容
退避保存領域が逼迫すると、最終アクセスが古いものから捨てられる
時間による削除Safari はサイト越えの追跡防止が有効なとき、ブラウザを使った日で 7 日間操作の無かったオリジンの、スクリプト由来のデータを消す

「翌週に開くと消えている」という症状は 2 つ目とよく合います。容量とは無関係に時間だけで発火するので、開発中は再現しません。

対応は navigator.storage.persist() の許可を求め、戻り値を確かめることです。許可されるとは限らないので、消えていたらサーバーから取り直す経路も併せて用意します。

ブラウザの保存領域を唯一の保管場所にしない、というのがこの章の設計指針です。

問 3. 画像ギャラリーをオフラインで見られるようにします。画像は 1 枚 2 MB 程度で 100 枚あります。IndexedDB と Cache API のどちらを使いますか。

解答と解説

答え: Cache API が向いています。

判断の分かれ目は「何をキーにして引くか」です。

機構キー向く用途
Cache APIRequest (URL)ネットワークから取ったものをそのまま再利用する
IndexedDB任意のキー。索引も張れる条件で検索する / 構造化データを更新する

画像は URL で引くので Cache API の形に合います。Response をそのまま保存できるので、fetch の結果を加工せずに入れられます。Service Worker を置けば、ページのコードを変えずにオフラインで返せます。

IndexedDB が向くのは、「撮影日が去年のものを出す」のように条件で絞る場合です。Blob を値として持てるので画像も保存できますが、URL で引くだけならキーの表現力が過剰です。

合計 200 MB は Web Storage の上限をはるかに超えますが、Cache API と IndexedDB はオリジン全体の quota で判定されるので、ディスクに余裕があれば収まります。navigator.storage.estimate() で事前に見積もれます。

まとめ

  • 仕様は storage key をオリジンの組と定めつつ、自ら「変わる見込み」と注記している
  • 3 つの実装はいずれも第三者文脈の保存領域を埋め込み先のサイトごとに分ける。細部は揃っておらず、WebKit はディスクにも書かない
  • 「保存した」と「残っている」は一致しない。既定は best-effort で退避の対象
  • Safari は時間だけで消す実装を持つ。容量に余裕があっても消える
  • IndexedDB は非同期でトランザクショナル。同期 API の Web Storage と違いメインスレッドを止めない
  • Cache API は RequestResponse の組を持ち、明示的に消すまで残る。Service Worker 専用ではない
  • 仕様が保存先ごとの容量を割り当てていないことは「上限なし」ではない。オリジン全体の quota がかかる
関連リファレンス

次に読む