SPAの帯域圧迫を解決する3つのキャッシュ戦略:インフラからReact Queryまで
本記事では、インフラ層、アプリ層、データ層の3つの視点から、SPAのパフォーマンスを劇的に改善する「多層的防御」アプローチを解説します。数メガバイトのバンドルサイズと戦う全エンジニア必見のキャッシュ戦略ガイドです。
- SPAとMPAの帯域消費モデルの違い
- HTTPキャッシュの正しい設定方法(Immutable vs No-Cache)
- Service Workerによるオフライン対応とキャッシュ戦略
- React Query/SWRによるAPI通信の最適化
- ChunkLoadErrorを防ぐデプロイ戦略
1. なぜSPAは帯域を圧迫するのか?MPAとの決定的な違い
SPA(Single Page Application)は、単一のHTMLページをロードし、JavaScriptでDOMを動的に書き換えるアーキテクチャです。これによりネイティブアプリのような操作感を実現しますが、その代償として「初期ロード時に大量の資産をダウンロードする必要がある」という課題を抱えています。
従来のMPA(Multi-Page Application)と比較すると、帯域消費のモデルは以下のように異なります。
SPAとMPAの帯域消費モデル比較
| 特性 | SPA (Single Page Application) |
MPA (Multi-Page Application) |
|---|---|---|
| 初期ロード | 大 (High) JSバンドル全体を一括DL 2〜10MB程度 |
小 (Low) 必要なHTMLのみDL 100〜500KB程度 |
| ページ遷移 | 極小 (Very Low) JSONデータのみ取得 数KB〜数十KB |
中 (Medium) HTML全体を再取得 100KB〜1MB |
| キャッシュ効率 | 2回目以降は ほぼゼロ通信 |
毎回HTML再取得 (一部キャッシュ可) |
| 損益分岐点 | 3〜5ページ閲覧後 から有利 |
直帰率が高い サイト向き |
SPAは「直帰ユーザー」には非効率ですが、回遊数が増えるほど帯域効率が良くなる「損益分岐点」を持っています。対策の鍵は、いかに初期ロードを減らし、2回目以降の通信をゼロにするかにあります。
よくある失敗例:バンドルサイズの肥大化
- 未使用のライブラリを放置 → バンドルサイズ12MB
- 画像を最適化せずインポート → 追加で8MB
- キャッシュ設定なし → 毎回20MB通信
- 結果: 4G回線で初期ロード45秒、直帰率78%
2. 【インフラ層】HTTPキャッシュの「ハイブリッド戦略」
最も強力で、コード変更なしに効果を出せるのがインフラ層(CDN・ブラウザキャッシュ)の最適化です。ここではファイルの種類によって戦略を使い分けることが重要です。
2.1 静的アセットには「Immutable」戦略
ビルドツール(Webpack, Vite等)によってハッシュ化されたファイル(例: main.a1b2c3.js)は、内容が変わればファイル名も変わるため、「不変(Immutable)」とみなせます。これらには最強のキャッシュ設定を適用します。
# JSファイル、CSSファイル、画像などの静的アセット
location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|woff2)$ {
add_header Cache-Control "max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
}
immutable ディレクティブを付与することで、ブラウザは再読み込み時でもサーバーへの確認(304 Not Modified確認)を行わず、ディスクキャッシュを即座に使用します。これにより、リロード時の通信が完全にゼロになります。
2.2 エントリーポイントには「No-Cache」戦略
逆に、index.html はアプリの入り口であり、ここが古いままだと新しいJSファイルにアクセスできません。したがって、「キャッシュしても良いが、必ずサーバーに確認する」設定が必要です。
# HTML(エントリーポイント)
location / {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri /index.html;
}
no-cache は「キャッシュしない」という意味ではありません。正確には「使う前にサーバーに有効性を確認せよ」という意味です。より厳格にキャッシュを防ぐには no-store を併用します。
2.3 CDNの活用で全世界高速化
キャッシュ設定が完璧でも、サーバーが遠ければ初回アクセスは遅いままです。CDN(Content Delivery Network)を導入することで、世界中のエッジサーバーから配信できます。
🚀 Tech Otaku Lab 推奨CDN構成
- Cloudflare: 無料プランでも高性能。Brotli圧縮対応
- AWS CloudFront: S3との連携が容易。invalidation機能が強力
- 国内CDN: 日本向けなら、さくらCDNやConoHaのCDNオプションも選択肢
3. 【アプリ層】Service Workerによる制御
インフラ層だけではカバーできないオフライン対応や微細な制御には、Service Worker(Workbox)を使用します。これはブラウザとサーバーの間に介在するプロキシのような役割を果たし、ネットワークリクエストをインターセプトできます。
主要なキャッシュ戦略の選択肢
推奨 Cache First 戦略
動作: まずキャッシュを確認し、あれば即座に返す。なければネットワークから取得してキャッシュに保存。
最適な対象: 画像、フォント、バージョニングされたJS/CSS
メリット: 帯域削減効果は最大。オフライン動作可能。
デメリット: 更新されたファイルを即座に反映できない(キャッシュクリアが必要)
検討 Stale While Revalidate
動作: キャッシュを即座に表示しつつ、裏で最新データを取得してキャッシュを更新。
最適な対象: API レスポンス、ニュースフィード、タイムライン
メリット: UXは爆速。古いデータでも即座に表示できる。
デメリット: 裏で通信が発生するため帯域削減効果は限定的
慎重に Network First 戦略
動作: まずネットワークから取得を試み、失敗したらキャッシュにフォールバック。
最適な対象: 常に最新が必要なデータ(在庫情報、価格など)
メリット: データの鮮度を最優先できる
デメリット: オンライン時は毎回通信が発生するため帯域節約効果なし
Workboxによる実装例
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// 画像: Cache First戦略(最大50件、30日間保持)
registerRoute(
({request}) => request.destination === 'image',
new CacheFirst({
cacheName: 'images-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30日
}),
],
})
);
// API: Stale While Revalidate(最大20件、1日保持)
registerRoute(
({url}) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 20,
maxAgeSeconds: 24 * 60 * 60, // 1日
}),
],
})
);
4. 【データ層】React Query / SWR による重複排除
SPA運用中の通信の主役はAPIコールです。React Query や SWR などのライブラリを導入することで、以下のメリットが得られます。
主要機能と帯域削減効果
| 機能 | 説明 | 帯域削減効果 |
|---|---|---|
| リクエストの重複排除 (Deduplication) |
同一キーへのリクエストを短期間(数ミリ秒)まとめて1回だけ実行 | 極大 複数コンポーネントが同時にデータ要求しても1回で済む |
| バックグラウンド再検証 (Background Revalidation) |
画面を開いたままでも定期的に最新データを取得 | 中 間隔を調整することで制御可能 |
| ウィンドウフォーカス時の再検証 | タブを戻った時に自動で最新データを取得 | 中 ユーザー体験は向上するが通信は発生 |
| Optimistic Update | サーバー応答を待たずにUIを更新(楽観的更新) | 大 体感速度が劇的に向上 |
React Queryの実装例
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5分間はフレッシュとみなす
cacheTime: 10 * 60 * 1000, // 10分間メモリに保持
refetchOnWindowFocus: false, // タブ切り替え時の再取得を無効化(帯域節約)
});
if (isLoading) return Loading...;
if (error) return Error: {error.message};
return {data.name};
}
staleTimeを長く設定(例: 5分〜10分)すると、同じデータへの再リクエストが減るrefetchOnWindowFocus: falseでタブ切り替え時の自動再取得を無効化refetchIntervalを設定しない(ポーリングを避ける)
SWRとの比較
| 項目 | React Query | SWR |
|---|---|---|
| 学習コスト | やや高め (機能が豊富) |
低い (シンプル) |
| バンドルサイズ | 約12KB (gzip) | 約5KB (gzip) |
| デフォルト動作 | 保守的 (通信控えめ) |
積極的 (頻繁に再検証) |
| 帯域削減 | 細かく制御可能 | 設定次第 |
staleTime を長めに設定する戦略がベスト。UX優先でリアルタイム性が必要なら SWR のデフォルト設定が優秀。
5. 運用上の落とし穴:ChunkLoadError対策
高度なキャッシュ戦略を導入したSPAで頻発するのが、ChunkLoadErrorです。ユーザーが古い index.html を開いている間に新しいデプロイが行われると、古いハッシュのJSファイルがサーバーから消えてしまい、アプリがクラッシュします。
発生メカニズム
古い index.html(v1)をロード。この時点ではJSはまだ読み込まれていない。
サーバー上のファイルが v1 から v2 に置き換わる。古いJSファイル(main.abc123.js)は削除される。
v1の index.html が参照する古いJSファイルを要求 → 404 Not Found → ChunkLoadError発生。
対策:アトミックデプロイと旧ファイルの保持
1. 古いファイルをすべて削除 2. 新しいファイルをアップロード → この間にアクセスしたユーザーは全員エラー
1. 新しいファイルを追加アップロード(古いファイルは残す) 2. index.html を最後に上書き 3. 古いファイルは24時間後に自動削除(cron等) → 移行期間中も両バージョンが共存
自動リトライの実装
それでもエラーが発生した場合に備えて、自動リロードの仕組みを入れておくと安心です。
import { useEffect } from 'react';
import { useRouteError } from 'react-router-dom';
export function ErrorBoundary() {
const error = useRouteError();
useEffect(() => {
// ChunkLoadError の検出
if (error?.name === 'ChunkLoadError' ||
error?.message?.includes('Loading chunk')) {
// 1回だけリロード(無限ループ防止)
const hasReloaded = sessionStorage.getItem('chunk-error-reload');
if (!hasReloaded) {
sessionStorage.setItem('chunk-error-reload', 'true');
window.location.reload();
}
}
}, [error]);
return アプリケーションエラーが発生しました;
}
6. パフォーマンス計測と改善の指標
対策を実施したら、必ず効果を計測しましょう。以下の指標をモニタリングすることで、改善度合いを定量化できます。
重要な指標(KPI)
| 指標 | 理想値 | 計測方法 |
|---|---|---|
| 初回ロード時間 (FCP) |
1.5秒以内 | Chrome DevTools Lighthouse |
| バンドルサイズ | 500KB以下 (gzip後) |
Webpack Bundle Analyzer vite-plugin-visualizer |
| キャッシュヒット率 | 80%以上 (2回目以降訪問) |
CDNダッシュボード Google Analytics |
| API呼び出し回数 | 必要最小限 | React Query DevTools Network Tab |
npx lighthouse https://your-spa-site.com \ --only-categories=performance \ --output=html \ --output-path=./lighthouse-report.html
7. 実践ワークフロー:既存SPAへの段階的導入
「いきなり全部やるのは大変」という方向けに、段階的な導入ロードマップを提示します。
・Lighthouse でスコア計測
・Webpack Bundle Analyzer で肥大化しているライブラリを特定
・未使用の依存関係を削除
・Nginx / CDN の Cache-Control ヘッダー設定
・Brotli 圧縮の有効化
・画像の WebP 変換と遅延読み込み(Lazy Load)
・React Query または SWR の導入
・不要な API ポーリングの停止
・staleTime の調整
・Workbox のセットアップ
・Cache First 戦略の適用
・オフライン対応の実装
React/Next.js特化のプログラミングスクールを見る
8. まとめ:多層防御で完璧なキャッシュ戦略を
SPAの帯域対策は、インフラ・アプリ・データの「多層的防御」で完成します。
- 1 静的アセット: Immutableキャッシュ(1年) + Brotli圧縮
- 2 HTML: no-cache で常に最新を確認
- 3 Service Worker: Cache First(画像)+ Stale While Revalidate(API)
- 4 API通信: React Query の staleTime=5分 で無駄撃ちを排除
- 5 デプロイ: 旧ファイルを24時間保持してChunkLoadError回避
これらを組み合わせ、「必要なものを、必要な時に、一度だけ送る」システムを構築しましょう。
よくある質問(FAQ)
Q1. Cache-Control の immutable は全ブラウザで動作する?
A. Firefox, Chrome, Safari は対応済みですが、古いブラウザ(IE11等)では無視されます。ただし無視されても害はなく、通常の max-age として機能します。
Q2. Service Worker は必須?
A. いいえ。HTTP キャッシュだけでも十分な効果があります。オフライン対応や高度な制御が必要な場合のみ導入を検討してください。
Q3. React Query と SWR、どちらを選ぶべき?
A. 大規模アプリや細かい制御が必要なら React Query。シンプルさ重視なら SWR。どちらも素晴らしいライブラリです。
Q4. CDNを使わなくてもキャッシュ効果はある?
A. はい。ブラウザキャッシュだけでも効果は絶大です。CDNはグローバル配信や初回アクセス高速化のための追加施策です。

コメント