HTTPキャッシュは正しく設定すれば劇的にパフォーマンスが上がるが、誤ると古いレスポンスが返り続けるバグを生む。「とりあえずCache-Control: no-cache」で済ませているなら、もう少し深く理解する価値がある。
HTTPキャッシュの仕組み
HTTPキャッシュは、一度取得したレスポンスを保存し、同じリクエストに対して再利用する仕組みだ。
クライアント → キャッシュ → オリジンサーバ
1. キャッシュヒット: キャッシュから返す(オリジンへのリクエストなし)
2. キャッシュミス: オリジンに問い合わせてキャッシュを更新
3. 条件付きGET: キャッシュが古い場合に変更確認だけ行う
Cache-Controlヘッダ
最も重要なキャッシュ制御ヘッダ。複数のディレクティブを組み合わせて使う。
レスポンスで使う主要ディレクティブ
# 300秒間キャッシュ(プライベートキャッシュのみ)
Cache-Control: private, max-age=300
# 1時間CDN・プロキシでもキャッシュ
Cache-Control: public, max-age=3600
# キャッシュするが毎回サーバに確認(ETagと組み合わせる)
Cache-Control: no-cache
# 一切キャッシュしない
Cache-Control: no-store
# CDN等の中間キャッシュの有効期限を別途指定(s-maxage)
Cache-Control: public, max-age=60, s-maxage=86400
ディレクティブの意味を正確に理解する
| ディレクティブ | 意味 |
|---|---|
max-age=N | N秒間はキャッシュを使う |
s-maxage=N | 共有キャッシュ(CDN・プロキシ)のmax-age |
private | ブラウザのみキャッシュ(CDN等は不可) |
public | 共有キャッシュ(CDN等)も許可 |
no-cache | キャッシュするが毎回検証する |
no-store | 一切保存しない |
must-revalidate | 期限切れ後は必ずサーバに確認 |
よくある誤解: no-cacheは「キャッシュしない」ではなく「毎回検証する」。キャッシュを完全に無効にしたいならno-storeだ。
ETagによる条件付きGET
ETag(Entity Tag)は、リソースの内容を識別するフィンガープリントだ。これを使うと、内容が変わっていない場合に本文の再送信を省ける。
# 最初のリクエスト
GET /api/users/123 HTTP/1.1
# レスポンス(ETagを含む)
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: no-cache
Content-Type: application/json
{ "id": 123, "name": "Taka" }
# 2回目のリクエスト(ETagを送る)
GET /api/users/123 HTTP/1.1
If-None-Match: "abc123"
# 内容が変わっていない場合
HTTP/1.1 304 Not Modified
ETag: "abc123"
# 内容が変わった場合
HTTP/1.1 200 OK
ETag: "xyz789"
Content-Type: application/json
{ "id": 123, "name": "Taka Updated" }
304 Not Modifiedが返ると、ブラウザはキャッシュのレスポンスをそのまま使う。ボディの転送がないため通信量が大幅に減る。
Last-Modified による条件付きGET
ETagの代わりに最終更新日時を使う方法。精度はETagより低い(1秒未満の変更は検知できない)が、実装が簡単だ。
# レスポンス
HTTP/1.1 200 OK
Last-Modified: Mon, 24 May 2026 12:00:00 GMT
Cache-Control: no-cache
# 次のリクエスト
GET /api/articles HTTP/1.1
If-Modified-Since: Mon, 24 May 2026 12:00:00 GMT
CDNキャッシュの設計
静的ファイルと動的APIでキャッシュ戦略を分けるのが基本だ。
静的ファイル(画像・JS・CSS)
# ファイル名にコンテンツハッシュを含める(例: main.abc123.js)
Cache-Control: public, max-age=31536000, immutable
immutableはブラウザに「このURLのリソースは変わらない」と伝え、再検証を省く。ファイル名のハッシュを変えることでキャッシュバストする設計だ。
HTML
# HTMLは短いmax-ageで、CDNには長めにキャッシュ
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
APIレスポンス(認証あり)
# 認証が必要なAPIはprivate
Cache-Control: private, max-age=60
APIレスポンス(公開データ)
# 公開APIはCDNでもキャッシュ
Cache-Control: public, max-age=300, stale-while-revalidate=60
stale-while-revalidate=Nは「期限切れ後N秒間は古いキャッシュを返しながら、バックグラウンドで再検証する」ディレクティブ。ユーザが待たずに済む。
Next.jsでのキャッシュ設定
// app/api/products/route.ts
export async function GET() {
const products = await fetchProducts();
return Response.json(products, {
headers: {
'Cache-Control': 'public, max-age=300, stale-while-revalidate=60',
},
});
}
// Server ComponentでのFetch
// revalidate: 定期的に再生成
const data = await fetch('/api/data', { next: { revalidate: 300 } });
// no-store: キャッシュなし
const live = await fetch('/api/live', { cache: 'no-store' });
キャッシュ設計のチェックリスト
□ 静的アセットはファイル名にハッシュを含めているか
□ HTMLのCache-Controlは適切か(長すぎると更新が反映されない)
□ 認証が必要なAPIにprivateを設定しているか
□ 公開APIにETがを実装しているか
□ CDNのキャッシュ無効化(Invalidation)の手段があるか
□ s-maxageとmax-ageを使い分けているか
よくあるミス
1. APIレスポンスをすべてno-storeにする
セキュリティ意識から何でもno-storeにすると、CDNの恩恵がゼロになる。公開データはキャッシュしていい。
2. Cache-Controlなしで配信する
ヘッダがないと、ブラウザやCDNが独自のルールでキャッシュを判断する。明示的に設定する。
3. no-cacheとno-storeを混同する
no-cacheはキャッシュはするが毎回検証する。キャッセュさせたくないならno-storeを使う。
まとめ
| シナリオ | 推奨設定 |
|---|---|
| 静的ファイル(ハッシュ付き) | public, max-age=31536000, immutable |
| HTML | public, max-age=0, s-maxage=3600 |
| 公開API | public, max-age=300, stale-while-revalidate=60 |
| 認証済みAPI | private, max-age=60 |
| リアルタイムデータ | no-store |
HTTPキャッシュの仕組みはWebを支える技術の9章(HTTPヘッダ)で体系的に解説されている。