📝 注意
本記事はAIの補助を受けて編集しています。
内容は大規模Webアプリケーションの実務経験に基づいています。
📚 目次
- 0. はじめに:なぜリロードのたびに全てを再読み込みするのか?
- 1. ブラウザキャッシュの全体像
- 2. HTTPキャッシュ – 最も簡単なキャッシュ
- 3. React Query / SWR – サーバー状態のキャッシュ
- 4. Service Worker – 最も強力なキャッシュ
- 5. Workboxによるキャッシュ戦略
- 6. LocalStorage vs IndexedDB – 大規模データのキャッシュ
- 7. Real User Monitoring (RUM) によるキャッシュ監視
- 8. アーキテクト向けチェックリスト
- 9. まとめと次回予告
0. はじめに:なぜリロードのたびに全てを再読み込みするのか?
こんな経験はありませんか?
- アプリを使い、リロードしたら 変更がないのに全てのリソースを再ダウンロード している
- 同じページを再訪問するのに初回よりも時間がかかる
- オフラインでも動作してほしい のに、どこから手をつければいいかわからない
もしこのような問題に直面しているなら、その原因は多くの場合 キャッシュの使い方 にあります。ロゴ、CSS、JS、画像の大部分は頻繁に変更されませんが、適切に指示されなければブラウザは毎回再ダウンロードしてしまいます。
Part 14 では次の問いに答えます:
「キャッシュを最大限に活用するには? HTTPキャッシュ、React Query、Service Worker、LocalStorage、IndexedDBをいつ使うべきか?」
具体例(結果はネットワーク・デバイス・戦略に依存します):
| 指標 | キャッシュ前 | キャッシュ戦略後 | 改善(目安) |
|---|---|---|---|
| LCP(2回目) | 2.4秒 | 0.4秒 | ↓約83% |
| JS/CSS再ダウンロード量 | 1.2 MB | 0KB(キャッシュ) | ↓100% |
| SPA画面遷移 | 1.2 MB | 0KB | ↓100% |
| オフライン動作 | ❌ | ✅(Service Worker) | — |
注記: 上記の数値は特定の構成での例であり、実際の結果は環境によって異なります。
1. ブラウザキャッシュの全体像
ブラウザのキャッシュは階層構造になっており、各層には異なる目的とメカニズムがあります。
1.1. キャッシュの種類と特性
| キャッシュの種類 | 保存場所 | タブを閉じても保持 | プログラミング可能 | オフライン対応 | 容量目安 |
|---|---|---|---|---|---|
| メモリキャッシュ | RAM | ✗ | ✗(ブラウザ管理) | ✗ | 数十MB |
| HTTPキャッシュ | Disk(HTTP cache) | ✓ | ✗(ヘッダのみ) | ✗ | ブラウザ管理(〜GB) |
| Service Worker Cache | Disk(CacheStorage) | ✓ | ✓(JavaScript) | ✓ | 数百MB〜GB |
| React Query / SWR | RAM(JSヒープ) | ✗(デフォルト) | ✓ | ✗(persist可) | RAM上限 |
| LocalStorage | Disk | ✓ | ✓ | ✓(保存のみ) | 〜5-10MB |
| IndexedDB | Disk | ✓ | ✓ | ✓ | 数百MB〜GB |
LocalStorageはオフラインでも読み書きできますが、これは単なる永続ストレージであり、オフラインファーストアーキテクチャを意味するものではありません。
1.2. キャッシュの選び方
各キャッシュの特徴:
- メモリキャッシュ:最速だが直接制御不可。ブラウザがヒューリスティックに管理。preloadやfetch priorityで間接的に影響を与えられる。
- HTTPキャッシュ:最も簡単。ヘッダ設定のみでコード不要。すべてのプロジェクトで有効。
- React Query / SWR:JavaScriptレベルでキャッシュ。UIと自動同期。SPAに最適。デフォルトRAM、persist可能。
- Service Worker:最強。完全にプログラム可能。オフライン動作を実現。
- LocalStorage:同期的でシンプルだが容量小。
- IndexedDB:非同期的で複雑だが大容量。構造化データに適する。
2. HTTPキャッシュ – 最も簡単なキャッシュ
HTTPキャッシュはサーバーが送信する レスポンスヘッダ に基づいて動作します。JavaScriptコードは一切不要です。
2.1. 重要なヘッダ
| ヘッダ | 意味 | 例 |
|---|---|---|
Cache-Control: max-age=N |
最大キャッシュ期間(秒)。この間はサーバーに問い合わせない |
max-age=86400(1日) |
Cache-Control: no-cache |
キャッシュはするが、使用前に毎回サーバーに検証させる | no-cache |
Cache-Control: no-store |
一切キャッシュしない | no-store |
Cache-Control: immutable |
リソースが永遠に変わらない(ハッシュ付きアセット用) | immutable |
ETag |
リソースのバージョン識別子 | "33a64df5" |
Last-Modified |
最終更新日時 | Wed, 21 Oct 2025 07:28:00 GMT |
ハッシュ付きアセットでの推奨設定:
Cache-Control: public, max-age=31536000, immutable
2.2. 検証キャッシュ(304)
リソースが期限切れ(max-age超過)だが 実際には変更されていない 場合、サーバーは 304 Not Modified(ボディなし)を返します。ブラウザは古いキャッシュを再利用します。
2.3. stale-while-revalidate – 速度と鮮度のバランス
この戦略はキャッシュを即座に返しつつ、バックグラウンドで新しいデータを取得します。
Cache-Control: max-age=300, stale-while-revalidate=60
| 時間経過 | 動作 |
|---|---|
| 0-300秒 | キャッシュ使用、サーバーに問い合わせない |
| 300-360秒 | キャッシュを即座に返す(速い)、同時にバックグラウンドでキャッシュ更新 |
| >360秒 | キャッシュは無効とみなされ、新規フェッチする |
実際の効果比較:
| 戦略 | 2回目読み込み | 新しいデータ反映までの遅延 |
|---|---|---|
| キャッシュなし | 〜2秒 | 0 |
max-age=86400 |
〜0ms(キャッシュ) | 24時間 |
stale-while-revalidate |
〜0ms(キャッシュ) | 数秒(バックグラウンド) |
この戦略は、即時性がそれほど重要でない部分(ユーザーアバター、商品画像、ニュースではない記事など)に特に有効です。
3. React Query / SWR – サーバー状態のキャッシュ
HTTPキャッシュはネットワーク層で動作しますが、UIと自動同期しません。React Query と SWR はこの問題を解決します:JavaScriptヒープ内でデータをキャッシュし、キャッシュ更新時に自動的にコンポーネントを再レンダリングします。
3.1. 動作の仕組み
3.2. 重要なパラメータ(TypeScript)
import { useQuery } from '@tanstack/react-query';
interface Article {
id: number;
title: string;
content: string;
}
const { data, isLoading } = useQuery<Article[]>({
queryKey: ['articles'],
queryFn: () => fetch('/api/articles').then(res => res.json()),
staleTime: 5 * 60 * 1000, // 5分: この間は再取得しない
gcTime: 10 * 60 * 1000, // 10分: 未使用後RAMから削除するまでの時間
});
| パラメータ | 意味 | パフォーマンスへの影響 |
|---|---|---|
staleTime |
データが「新鮮」と見なされる期間。この間は再取得しない | APIリクエスト数を削減 |
gcTime |
コンポーネントがunmountされてからキャッシュをRAMに保持する時間 | メモリ節約 |
refetchOnWindowFocus |
ユーザーがタブに戻った時に再取得するか | データの鮮度を維持 |
永続化について: デフォルトではReact Queryのキャッシュは現在のタブのメモリ内にのみ存在します。リロードやタブ再起動後もキャッシュを維持したい場合は、
persistQueryClientと localStorage/IndexedDBを組み合わせることができます。
3.3. APIデータ種別ごとのキャッシュ戦略
| データ種別 | staleTime |
gcTime |
理由 |
|---|---|---|---|
| ユーザープロフィール、設定 | 10-30分 | 30分 | 頻繁に変更されない |
| 記事、商品一覧 | 2-5分 | 5-10分 | 変更されうるが即時性はそれほど重要でない、短いstaleを許容 |
| リアルタイムデータ(価格、在庫、決済、注文) | 0(デフォルト) | 5分 | 常に最新データが必要、SWRは不適切 |
注記:
StaleWhileRevalidate(SWR)戦略は短期的なstaleを許容できるAPI(記事一覧、アバター、商品カタログなど)に適しています。決済・注文・在庫などのリアルタイムデータには適しません。
4. Service Worker – 最も強力なキャッシュ
Service Workerはバックグラウンドで動作するスクリプトで、ブラウザとサーバーの間に立ち、すべてのリクエストに介入できます。
4.1. Cache Storage – 保存場所
Cache StorageはHTTPキャッシュとは 完全に独立したストレージ です。API的には、サーバーが Cache-Control: no-store を送信していてもキャッシュ可能です。ただし、認証付きリクエストや機密データ、ブラウザのセキュリティポリシーには注意が必要です。
Cache Storageはほとんどの場合HTTPキャッシュヘッダから独立して動作します。Service Workerがどのレスポンスを保存するか能動的に決定できるためです。
4.2. Service Workerのライフサイクル
最小限の例(本番用ではない、概念理解用):
// sw.js
const CACHE_NAME = 'my-app-v1';
const urlsToCache = [
'/',
'/index.html',
'/static/css/main.css',
'/static/js/main.js',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
// 本番ではメソッドチェックを必須とする
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
⚠️ 本番環境での注意事項:上記は基本的な仕組みを説明するための例です。本番では:
- 闇雲にすべてのリクエストをキャッシュしない
- POST/PUT/DELETEに適用しない
- HTMLやAPI、ミューテーションリクエストには個別の戦略が必要
- キャッシュ無効化(ハッシュ付きファイル名、バージョニング)を適切に処理する
4.3. ReactでのService Worker登録(Vite)
// main.tsx
const isSWSupported = 'serviceWorker' in navigator;
// Viteでは import.meta.env.PROD を使用
if (isSWSupported && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(registration => {
console.log('SW registered: ', registration);
}).catch(error => {
console.log('SW registration failed: ', error);
});
});
}
4.4. Service Worker CacheはHTTPヘッダの影響を受けない(但し注意点あり)
ほとんどの場合、Cache Storageは Cache-Control から独立しています。ただし、認証付きレスポンスや機密データ、特殊なポリシー(例:credentials: 'include')を持つリクエストには注意が必要です。異なるブラウザでの動作を常に確認しましょう。
4.5. キャッシュ無効化 – キャッシュ最大の課題
キャッシュを扱う上で最も難しい問題の一つが 無効化(invalidation) です。アプリケーションの新しいバージョンをデプロイしても、ユーザーは古いアセットをキャッシュから受け取り続け、表示崩れやロジックエラーが発生する可能性があります。
一般的な解決策:
-
ハッシュ付きファイル名:ファイル名にハッシュを含める(例:
main.a1b2c3.js)。内容が変わればファイル名も変わる → ブラウザは新しいリソースと認識。 -
バージョン付きキャッシュキー:新しいバージョンで新しいキャッシュ名(例:
my-app-v2)を使用。activateで古いキャッシュを削除。 -
skipWaiting()とclients.claim():install内でskipWaiting()を呼び出すと、待機せず即座に新SWが有効化。activate内でclients.claim()を呼び出すと、開いているクライアントを制御下に置く。注意して使用しないと、実行中のページと新しいSWのバージョン不一致を起こす可能性がある(例:古いJS chunkが新しいバージョンを要求して失敗)。 - ロールバック戦略:新しいバージョンでエラーが発生した場合に、古いバージョンにフォールバックできるようにしておく。
より良いUXのためのアップデートフロー:多くの本番アプリでは、強制的に即時有効化する代わりに、"新しいバージョンがあります → 更新してください" という通知を表示します。これによりユーザーに不意のエラーや困惑を防げます。
5. Workboxによるキャッシュ戦略
WorkboxはGoogleが提供するライブラリで、Service Workerによるキャッシュを簡単に実装できます。5つの主要なキャッシュ戦略を標準で提供しています。
5.1. 5つのコア戦略
| 戦略 | 動作 | ユースケース | 例 |
|---|---|---|---|
| Cache First | キャッシュ優先、なければネットワーク | 変更頻度の低いアセット、速度重視 | ロゴ、フォント、CSS、JSライブラリ |
| Network First | ネットワーク優先、失敗時キャッシュ | 最新データが必要でオフラインフォールバックも欲しいAPI | ニュース、株価、通知一覧 |
| Stale While Revalidate | キャッシュを即時返し、バックグラウンドで更新 | 速度と鮮度のバランス | ユーザーアバター、商品画像、記事プレビュー |
| Cache Only | キャッシュのみ(なければ失敗) | 極めて安定したプリキャッシュアセットに限定。キャッシュミス即失敗のため一般的ではない | プリキャッシュ済みアプリシェル |
| Network Only | ネットワークのみ(キャッシュしない) | キャッシュできないリクエスト、ミューテーション | POSTリクエスト、分析 |
5.2. Workboxのセットアップ(React + Vite)
vite-plugin-pwa プラグインを使用します。このプラグインは内部でWorkboxを利用しています。
npm install vite-plugin-pwa --save-dev
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
// 画像はCache First
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/,
handler: 'CacheFirst',
options: {
cacheName: 'images-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 30 * 24 * 60 * 60 },
},
},
// APIはStaleWhileRevalidate(短期的staleを許容できるAPIのみ)
{
urlPattern: /^https:\/\/api\.example\.com\/.*/,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'api-cache',
expiration: { maxEntries: 100, maxAgeSeconds: 5 * 60 },
},
},
],
},
}),
],
});
5.3. リソース種別ごとの戦略選択
6. LocalStorage vs IndexedDB – 大規模データのキャッシュ
構造化データの保存、オフライン検索、大容量ストレージが必要な場合、LocalStorageとIndexedDBが選択肢となります。
6.1. 簡単な比較
| 特徴 | LocalStorage | IndexedDB |
|---|---|---|
| 容量 | 〜5-10MB | 数百MB〜GB |
| API | 同期的、シンプル | 非同期的(Promise/async)、やや複雑 |
| 保存形式 | 文字列のキーバリュー | オブジェクト、インデックス、トランザクション |
| 検索 | ✗(全件走査必須) | ✓(インデックス、範囲検索) |
| ユースケース | テーマ、トークン、ユーザー設定 | オフラインデータ(商品、記事)、複雑なキャッシュ |
6.2. IndexedDBを使うべき場合
- オフラインファーストアプリで数千件のレコードを保存する必要がある
- オフライン検索が必要(テキスト検索、カテゴリフィルタ)
- データ量が5MBを超える
6.3. LocalStorageへの設定保存の例
// ⚠️ LocalStorageのAPI例です。本番ではトークンをLocalStorageに保存しないでください(XSSリスク)
// アクセストークンはHttpOnly Cookieなど、セキュリティモデルに合った方法を選びましょう。
// テーマを保存
localStorage.setItem('theme', 'dark');
// テーマを取得
const theme = localStorage.getItem('theme');
// ログアウト時に削除
localStorage.removeItem('theme');
セキュリティ警告:上記はLocalStorageのAPI例です。本番環境では 認証トークンをLocalStorageに保存してはいけません(XSS攻撃を受けると盗まれる危険性があります)。代わりにHttpOnly CookieとCSRF対策などを使用してください。
パフォーマンス:LocalStorageは同期的で、大量データや頻繁なアクセスがあるとメインスレッドをブロックする可能性があります。大きなデータにはIndexedDBを優先しましょう。
7. Real User Monitoring (RUM) によるキャッシュ監視
キャッシュ戦略を立てるだけでは不十分です。本番環境でキャッシュが期待通りに機能しているかを 監視 することが重要です。監視により:
- キャッシュが意図通りに機能していない問題を早期発見
- リソース種別ごとのTTFBやキャッシュヒット率を測定
- ユーザーが不満を感じる前に影響を検出
7.1. 監視すべき指標
| 指標 | 意味 | ツール例 |
|---|---|---|
| キャッシュヒット率 | キャッシュから提供されたリクエストの割合 | Datadog RUM, New Relic, Grafana Faro, Sentry Performance |
| Time To First Byte (TTFB) | 最初のバイトが到着するまでの時間 | Chrome DevTools, RUM |
| FCP / LCP 改善度 | キャッシュによる表示速度の改善 | Lighthouse, RUM |
| キャッシュステータス別のリクエスト時間 | キャッシュ有無での応答時間比較 | RUMプラットフォーム |
7.2. Datadog RUM設定例(多くの選択肢のうちの一つ)
Datadog RUMの他に、New Relic Browser、Grafana Faro、Sentry Performance、自作など様々な選択肢があります。以下はDatadogの例です。
// datadog-rum.ts
import { datadogRum } from '@datadog/browser-rum';
datadogRum.init({
applicationId: 'your-app-id',
clientToken: 'your-client-token',
site: 'datadoghq.com',
service: 'frontend-cache-example',
env: 'production',
version: '1.0.0',
sessionSampleRate: 100,
sessionReplaySampleRate: 20,
trackInteractions: true,
trackResources: true,
trackLongTasks: true,
});
7.3. キャッシュヒット率ダッシュボードの構築
RUMを導入したチームの経験から、キャッシュダッシュボードには以下を含めるべきです:
- リソース種別ごとのキャッシュヒット率(CSS / JS / 画像 / API)
- キャッシュステータス別のLCP(ヒット vs ミス)
- キャッシュステータス別のレスポンス時間分布
- キャッシュヒット率急低下のアラート(例:静的アセットで80%を下回った場合)
8. アーキテクト向けチェックリスト
HTTPキャッシュ(静的アセット向け)
-
すべての静的アセットに適切な
Cache-Controlヘッダが設定されている -
CSS/JSビルド成果物に
max-age=31536000+immutable+ [contenthash] を使用(キャッシュ無効化自動化) -
HTML(メインページ)は
Cache-Control: no-cache(常に検証) -
画像、アバター、記事プレビューなどに
stale-while-revalidateを検討する
React Query / SWR(サーバー状態向け)
-
データ種別ごとに
staleTimeを適切に設定(プロフィール:30分、記事:5分、リアルタイム:0) -
gcTime(React Query v5)を使用して未使用キャッシュをクリーンアップ -
リロード後もキャッシュを維持したい場合は
persistQueryClientを検討 - リアルタイムデータ(決済、注文、在庫)にはSWRを使用しない
Service Worker(オフライン&パフォーマンス向け)
- Workboxをインストールするか、サービスワーカーを手動実装する
-
コアとなるリソース(アプリシェル)を
install時にプリキャッシュする -
新しいバージョンで古いキャッシュを削除(
activate内) - リソース種別に適したキャッシュ戦略を選択する
- キャッシュ無効化を適切に処理する(ハッシュ付きファイル名、バージョンキャッシュ)
-
skipWaiting()とclients.claim()は慎重に扱い、"新しいバージョン → 更新してください" のUXを検討する -
SW登録時は環境変数に応じて適切なチェックを行う(Vite:
import.meta.env.PROD、Webpack/CRA:process.env.NODE_ENV === 'production')
LocalStorage / IndexedDB
- LocalStorageはUI状態やユーザー設定、トークンではないものに限定する
- 大容量・複雑なキャッシュにはIndexedDBを使用する
- 大量データを同期的にLocalStorageに保存しない(メインスレッドをブロックする)
キャッシュ監視
- RUM(Datadog, New Relic, Grafana Faro, Sentry など)を設定してキャッシュヒット率を監視する
- TTFBやキャッシュステータス別のレスポンス時間をダッシュボード化する
- キャッシュヒット率が急低下した場合のアラートを設定する
9. まとめと次回予告
| キャッシュの種類 | 保存場所 | オフライン | UI自動同期 | プログラミング | 用途 |
|---|---|---|---|---|---|
| メモリキャッシュ | RAM | ✗ | ✗ | ブラウザ管理 | 自動(ブラウザ管理) |
| HTTPキャッシュ | Disk | ✗ | ✗ | ヘッダ | 静的アセット、画像、フォント |
| React Query / SWR | RAM(永続化可) | ✗(デフォルト) | ✓ | useQuery + persist | APIデータ(stale許容) |
| Service Worker | Disk(CacheStorage) | ✓ | ✗ | ✓ | オフライン、PWA、カスタムロジック |
| LocalStorage | Disk | ✓(保存のみ) | ✗ | ✓ | 設定、テーマ(5MB未満) |
| IndexedDB | Disk | ✓ | ✗ | ✓ | 大規模データ、オフライン検索 |
👉 次回予告(Part 15):
[Frontend Performance - Part 15] 配信最適化:CDNとネットワークで表示速度を改善する
