Webサイトを構築するとき、ユーザーが画面を開き直すたびにサーバーへリクエストが飛ぶのはパフォーマンス上の問題になりやすい。本記事ではブラウザ側で利用できるキャッシュ・ストレージの仕組みを、全体像から順に整理する。
1. 全体像:キャッシュの種類と目的
ブラウザで使えるキャッシュ・ストレージは大きく2つの目的に分類できる。
通信の効率化(キャッシュ)
├── HTTPキャッシュ → サーバーのヘッダー設定だけ、一番シンプル
├── React Query / SWR → UIと自動連携、SPAに最適
└── Service Worker → オフライン対応、最も強力
データの永続化(ストレージ)
├── LocalStorage → 設定・UI状態(5MB以内)
└── IndexedDB → 大量の構造化データ、オフライン検索
それぞれ保存場所・有効期間・用途が異なる。
| HTTPキャッシュ | React Query | Service Worker | LocalStorage | IndexedDB | |
|---|---|---|---|---|---|
| 保存場所 | ディスク | RAM | ディスク | ディスク | ディスク |
| ページを閉じても残る | ✓ | ✗ | ✓ | ✓ | ✓ |
| オフラインで動く | ✗ | ✗ | ✓ | ✓ | ✓ |
| UIと自動連携 | ✗ | ✓ | ✗ | ✗ | ✗ |
| 容量 | ブラウザ管理 | 数十MB | 数十〜数百MB | 〜5MB | 数百MB〜GB |
| インストール | 不要 | 不要 | 不要 | 不要 | 不要 |
2. ブラウザのメモリ構造を理解する
キャッシュの話をする前に、ブラウザがどのようにメモリを使っているかを把握しておく。
モダンブラウザはタブごとに独立したプロセスを起動する。1つのページを開いただけで、以下のようにメモリが消費される。
| 順位 | 項目 | 目安 | 補足 |
|---|---|---|---|
| 1 | 画像・動画のデコードデータ | 数十〜数百MB | 圧縮前のフルサイズがRAMに展開される |
| 2 | GPUテクスチャ/コンポジットレイヤー | 数十〜100MB以上 | CSSアニメーション、will-changeなどで生成 |
| 3 | JavaScriptヒープ(GC管理) | 数MB〜100MB | SPAやフレームワークほど増加 |
| 4 | DOMツリー | 数MB〜数十MB | ノード数が多いほど増加 |
| 5 | CSSスタイル計算結果 | 数MB〜十数MB | 全ノードへの適用済みスタイル |
実際のメモリ使用状況はブラウザのDevToolsで確認できる。F12 → Memory タブ → Take snapshot を実行すると、現時点のJavaScriptヒープの内訳がオブジェクトごとに表示される。どのオブジェクトがどれだけメモリを占有しているかを特定するのに役立つ。
特に画像は要注意で、2000×1500のJPEGはファイルサイズ500KBでも、デコード後は以下のサイズになる。
2000 × 1500 × 4バイト(RGBA)= 約12MB
ページに20枚あれば240MBになる。
またRAM上のキャッシュとディスク上のキャッシュでは性質が大きく異なる。
| 種類 | 保存場所 | 特徴 |
|---|---|---|
| メモリキャッシュ | RAM | 高速だがタブを閉じると消える |
| ディスクキャッシュ | SSD / HDD | ブラウザを閉じても残る |
3. HTTPキャッシュ
最もシンプルなキャッシュ。サーバーのレスポンスヘッダーを設定するだけで動く。
保存場所
ブラウザのディスク上に保存される。
Chrome (Windows): C:\Users\{user}\AppData\Local\Google\Chrome\User Data\Default\Cache
Chrome (Mac): ~/Library/Caches/Google/Chrome/Default/Cache
Firefox (Windows): C:\Users\{user}\AppData\Local\Mozilla\Firefox\Profiles\{profile}\cache2
Firefox (Mac): ~/Library/Caches/Firefox/Profiles/{profile}/cache2
取得ロジック
リクエスト発生
↓
メモリキャッシュにあるか? → YES → 即返す(RAM)
↓ NO
ディスクキャッシュにあるか? → NO → サーバーへリクエスト
↓ YES
有効期限内(max-age未超過)か? → YES → そのまま返す(通信ゼロ)
↓ NO
ETag / Last-Modified があるか? → NO → フルリクエスト
↓ YES
条件付きリクエスト送信(If-None-Match / If-Modified-Since)
├─ 304 Not Modified → キャッシュを再利用(ボディなし、軽い)
└─ 200 OK → 新データを保存して返す
主要なヘッダー
Cache-Control: max-age=300, stale-while-revalidate=60
-
max-age=300:300秒間はサーバーに問い合わせすらしない -
stale-while-revalidate=60:期限切れでも古いデータを即返しつつ、バックグラウンドで更新
304レスポンスはボディなしなので、通信量を大幅に削減できる。
向いている用途
画像・フォント・CSS・JSなど、変更頻度が低い静的ファイル全般。
4. React Query / SWR
HTTPキャッシュはUIと連携しない。React QueryやSWRはJavaScript側でキャッシュを管理し、データが更新されたら自動で再レンダリングしてくれる。
HTTPキャッシュとの違い
| HTTPキャッシュ | React Query / SWR | |
|---|---|---|
| 管理場所 | ブラウザ(ディスク・RAM) | JavaScriptのメモリ(ヒープ) |
| 管理者 | ブラウザ自身 | アプリのコード |
| 単位 | URL | クエリキー(自由に定義) |
| UIとの連携 | なし | 自動で再レンダリング |
保存場所
JavaScriptヒープ上のグローバルなMapオブジェクト。タブを閉じると消える。
// 内部イメージ
const cache = new Map([
['articles', { data: [...], updatedAt: 1714180000 }],
['articles', '1', { data: {...}, updatedAt: 1714180001 }],
])
取得ロジック
useQuery() が呼ばれる
↓
キャッシュ(Map)にこのキーがあるか?
├─ NO → loading表示 → APIフェッチ → UIを再レンダリング
└─ YES → キャッシュを即座に表示
↓
staleTime を超えたか?
├─ NO(新鮮) → そのまま表示継続
└─ YES → バックグラウンドでリフェッチ
↓
成功 → キャッシュ更新 → UIを再レンダリング
失敗 → リトライ or エラー表示
↓
gcTime 経過後 → メモリから解放(GC)
主要な設定
useQuery({
queryKey: ['articles'],
queryFn: () => fetch('/api/articles').then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5分間はフレッシュ(再フェッチしない)
gcTime: 10 * 60 * 1000, // 10分後にメモリから解放
})
-
staleTime:キャッシュを「新鮮」とみなす時間。CMSの記事データなら5〜10分が現実的。デフォルトは0(毎回バックグラウンドリフェッチが走る) -
gcTime:staleTimeより長く設定するのが基本
向いている用途
SPAのAPIデータ取得全般。Headless CMSの記事一覧・記事内容など。
5. Service Worker
ブラウザとサーバーの間に入る独立したスレッド。全リクエストを横取りしてキャッシュを返せるため、オフライン対応まで可能な最も強力な仕組み。
他との違い
| HTTPキャッシュ | React Query | Service Worker | |
|---|---|---|---|
| 動く場所 | ブラウザ内部 | JSヒープ | 独立したスレッド |
| 保存場所 | ディスク | RAM | Cache Storage(ディスク) |
| ページを閉じても残る | ✓ | ✗ | ✓ |
| オフラインで動く | ✗ | ✗ | ✓ |
| JSコードで制御 | ✗ | ✓ | ✓ |
保存場所
HTTPキャッシュとは完全に別の専用領域「Cache Storage」に保存される。ブラウザのDevToolsの Application → Cache Storage で確認できる。
ライフサイクル
① install → 静的ファイルをCache Storageに保存(一度だけ)
② activate → 古いキャッシュを削除・引き継ぎ(一度だけ)
③ fetch → 全リクエストを横取りしてキャッシュorサーバーへ(毎回)
3つのキャッシュ戦略
用途に応じて戦略を使い分ける。
① Cache First(キャッシュ優先)
キャッシュがあれば即返す。なければサーバーへ。
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request).then(response => {
caches.open('v1').then(c => c.put(event.request, response.clone()));
return response;
});
})
);
});
用途:画像・フォント・CSSなど変更の少ないファイル
② Network First(ネットワーク優先)
常にサーバーへ。オフライン時だけキャッシュにフォールバック。
self.addEventListener('fetch', event => {
event.respondWith(
fetch(event.request)
.then(response => {
caches.open('v1').then(c => c.put(event.request, response.clone()));
return response;
})
.catch(() => caches.match(event.request))
);
});
用途:APIレスポンスなど常に最新が必要なデータ
③ Stale While Revalidate(両立)
キャッシュを即返しつつ、バックグラウンドで更新。
self.addEventListener('fetch', event => {
event.respondWith(
caches.open('v1').then(cache => {
return cache.match(event.request).then(cached => {
const fetchPromise = fetch(event.request).then(response => {
cache.put(event.request, response.clone());
return response;
});
return cached || fetchPromise;
});
})
);
});
用途:CMSの記事など多少古くてもOKだが更新もしたいデータ
6. LocalStorage と IndexedDB
これらはキャッシュというよりもアプリの状態・データを永続化するためのストレージ。どちらもインストール不要でブラウザ標準搭載。
LocalStorage
シンプルなキーバリュー形式。容量は5MB程度。
localStorage.setItem('theme', 'dark');
localStorage.getItem('theme'); // → 'dark'
有効期限の仕組みがないため、APIデータのキャッシュには向かない。UIの設定・言語設定・既読管理など小さなデータに使う。
IndexedDB
ブラウザ標準搭載の本格的なデータベース。数百MB〜GB級のデータをインデックス検索できる。
| LocalStorage | IndexedDB | |
|---|---|---|
| 容量 | 〜5MB | 数百MB〜GB級 |
| データ形式 | 文字列のみ | JSオブジェクトそのまま |
| 検索 | なし | インデックス検索可 |
| 非同期 | 同期(UIブロック) | 非同期 |
保存場所はブラウザのディスク上。
Chrome (Windows): C:\Users\{user}\AppData\Local\Google\Chrome\User Data\Default\IndexedDB\
Chrome (Mac): ~/Library/Application Support/Google/Chrome/Default/IndexedDB/
Firefox (Windows): C:\Users\{user}\AppData\Roaming\Mozilla\Firefox\Profiles\{profile}\storage\
Firefox (Mac): ~/Library/Application Support/Firefox/Profiles/{profile}/storage/
素のAPIはやや冗長なので、ラッパーライブラリのDexie.jsを使うのが現実的。
import Dexie from 'dexie';
const db = new Dexie('my-app-db');
db.version(1).stores({
articles: '++id, title, date',
});
// 書き込み
await db.articles.put({ title: '記事A', body: '...', date: new Date() });
// インデックス検索
const recent = await db.articles.where('date').above(someDate).toArray();
7. ユーザーへの許可ダイアログは必要か
| 機能 | ダイアログ |
|---|---|
| LocalStorage | 不要 |
| IndexedDB | 不要 |
| Cookie | 不要 |
| Service Worker | 不要 |
| 位置情報・通知・カメラ | 必要 |
CookieバナーはCookieを使うから出すのではなく、個人情報を追跡するから出す。
GDPRや個人情報保護法などの法律上の要件であり、技術的な制約ではない。記事キャッシュ用途であれば同意バナーは不要。
ただし以下の点は注意が必要。
- 容量超過時にブラウザが古いデータを自動削除することがある
- プライベートブラウジングではセッション終了時にすべて消える
- ユーザーが「サイトのデータを消去」するとすべて消える
8. Headless CMS × Reactでのおすすめ構成
| リソース | おすすめ戦略 | 理由 |
|---|---|---|
| 画像・フォント・CSS | Service Worker(Cache First) | ほぼ変わらない |
| 記事一覧・記事内容 | React Query + Service Worker(Stale While Revalidate) | 多少古くてもOK、更新もしたい |
| ユーザー固有データ | Service Worker(Network First) | 常に最新が必要 |
| UI設定・言語 | LocalStorage | 小さなデータ、永続化が必要 |
| 大量記事のオフライン対応 | IndexedDB + Dexie.js | 構造化・検索が必要 |
導入ステップ
一度にすべてを実装する必要はない。以下の順番で段階的に導入するのが現実的。
Step 1: HTTPキャッシュ(Cache-Controlヘッダーの設定)
→ コード変更ほぼなし、即効性が高い
Step 2: React Query の導入
→ UIとデータ取得の分離、キャッシュの自動管理
Step 3: Service Worker の追加(Workbox を使うと実装が楽)
→ オフライン対応、より強力なキャッシュ制御
Step 4: IndexedDB の追加(必要な場合のみ)
→ 大量データのオフライン検索が必要になったとき
HTTPキャッシュとReact Queryの併用がまず現実的な第一歩。オフライン対応が必要になったタイミングでService WorkerとIndexedDBを追加していく形がおすすめ。