HTTP Cache-Control 徹底解説:max-age・s-maxage・must-revalidate・immutable・stale-while-revalidate の使い分け
Cache-Control ヘッダは「max-age=3600 を付ければよい」と覚えがちですが、実際には 10 種類以上のディレクティブがあり、組み合わせで挙動が大きく変わります。「ブラウザがキャッシュしない」「CDN が想定と違う TTL で運用していた」「無効化したつもりが古いレスポンスが残る」など、実務で踏むほぼすべてがディレクティブの相互作用の理解不足から来ます。本記事では主要ディレクティブを役割別に整理します。
ディレクティブの 4 つの役割
Cache-Control のディレクティブは大きく 4 種類に分類できます:
- キャッシュの可否:
no-store/private/public - 新鮮さ(freshness):
max-age/s-maxage - 再検証:
no-cache/must-revalidate/proxy-revalidate - 変更されない約束:
immutable/stale-while-revalidate/stale-if-error
各役割の中で 1 つだけ使うのが基本で、複数役割の組合せがレスポンスの実際の挙動を決めます。
キャッシュの可否
no-store:絶対に保存しない
Cache-Control: no-store もっとも厳しい指定。ブラウザにも CDN にもキャッシュさせない。クレジットカード番号・個人情報など、ディスクに残ってはいけないデータに使う。
no-store を付けたレスポンスは:
- ブラウザのディスクキャッシュに書かない
- CDN のエッジキャッシュに置かない
- リバースプロキシ(Varnish 等)も保持しない
ただしメモリ上の一時保持(HTTP/2 の DATA フレーム再送信用バッファ等)は妨げません。
private vs public
Cache-Control: public, max-age=3600
Cache-Control: private, max-age=3600 | ディレクティブ | ブラウザ | CDN / 共有プロキシ |
|---|---|---|
public | ✓ キャッシュ可 | ✓ キャッシュ可 |
private | ✓ キャッシュ可 | ✗ キャッシュ不可 |
private は「ユーザー個別の情報を含むからユーザーのブラウザだけに置け」のセマンティクス。ログイン後のダッシュボード、メール本文などに使う。
Authorization ヘッダがあるリクエストの罠
Authorization ヘッダ付きのリクエストへのレスポンスは、デフォルトで共有キャッシュに保存されません(RFC 9111 §3.5)。public を明示しないと CDN を通っても TTL がつかない、というのが Bearer トークン使用時に頻発する事故。
GET /api/users HTTP/1.1
Authorization: Bearer ...
# ↓ レスポンス側で公開キャッシュ可能であることを明示
Cache-Control: public, max-age=300 新鮮さ(freshness)
max-age vs s-maxage
Cache-Control: max-age=60, s-maxage=3600 max-age:すべてのキャッシュ(ブラウザ + CDN)に適用される TTL(秒)s-maxage:共有キャッシュ(CDN・プロキシ)にだけ適用、ブラウザは無視
「ブラウザは 60 秒、CDN は 1 時間キャッシュ」のような設計に使う。ブラウザでは新鮮さを短くしてユーザーに最新を見せ、CDN では長くしてオリジンへの負荷を減らす、というのが定番パターン。
Expires との関係
Cache-Control: max-age=3600
Expires: Thu, 01 Dec 2026 16:00:00 GMT 両方ある場合は Cache-Control: max-age が優先されます(RFC 9111 §5.3)。Expires は HTTP/1.0 互換のため残しているケースがほとんどで、現代では max-age だけで十分。
再検証
no-cache ≠「キャッシュしない」
最も誤解されるディレクティブ。no-cache は「毎回オリジンに確認してから使う」であって、キャッシュを無効化する指示ではありません。
Cache-Control: no-cache 挙動:
- ブラウザはレスポンスをキャッシュに保存する
- 次回そのリソースが要るとき、条件付きリクエスト(
If-None-Match/If-Modified-Since)をオリジンに送る - オリジンが
304 Not Modifiedを返せば、キャッシュ済みのレスポンスを利用
「保存はする、ただし使う前に毎回再検証する」という挙動。max-age=0 と組み合わせて書かれることもあります:
Cache-Control: no-cache, max-age=0
# これは "no-cache" 単独と等価 must-revalidate
max-age で新鮮さが切れた後の挙動を制御:
Cache-Control: max-age=3600, must-revalidate - 新鮮(max-age 内):そのまま使う
- 期限切れ:必ずオリジンに再検証する。オリジンに繋がらなければ エラーを返す(古いレスポンスを返さない)
must-revalidate がない場合、ネットワーク不通時に古いレスポンスを返してよいことが RFC 9111 §4.2.4 で許容されています。古い情報を絶対に出したくないなら must-revalidate を明示。
proxy-revalidate
must-revalidate の共有キャッシュ版。CDN / プロキシだけに「期限切れ後は必ず再検証せよ」を要求。ブラウザには影響しない。
「変更されない約束」と stale 配信
immutable
Cache-Control: max-age=31536000, immutable 「この URL の中身は変わらない」という強い宣言。ブラウザは max-age 内で再検証を一切スキップする。
ファイルパスにハッシュが含まれるアセット(/static/app.a3f8c2.js)が典型的な対象。バンドラが生成する hashed URL は内容が変わると URL も変わるので、immutable で 1 年キャッシュしても安全。
stale-while-revalidate
Cache-Control: max-age=300, stale-while-revalidate=86400 「期限切れ後 1 日間は古いキャッシュを返してよい、ただしバックグラウンドで再検証する」というセマンティクス。
- 0-300 秒:新鮮なキャッシュを返す
- 300-86700 秒:古いキャッシュをすぐ返しつつ、裏でオリジンに新規取得
- 86700 秒以降:通常の期限切れ動作
ユーザー体験を犠牲にせずオリジン負荷を減らせる。Cloudflare・Fastly・Vercel などの CDN で広くサポート。
stale-if-error
Cache-Control: max-age=300, stale-if-error=86400 「期限切れ後でも、オリジンがエラーを返したら古いキャッシュで応答する」。サービス障害時の fallback として使う。
ディレクティブの組み合わせ早見表
| 用途 | 推奨組み合わせ |
|---|---|
| 静的アセット(hash 付き URL) | public, max-age=31536000, immutable |
| 静的アセット(hash なし) | public, max-age=300, stale-while-revalidate=86400 |
| HTML(重要な更新を即座に反映) | private, no-cache |
| ログイン後の API レスポンス | private, max-age=0, must-revalidate |
| 機密データ(クレカ、個人情報) | no-store |
| CDN 長期 + ブラウザ短期 | public, max-age=60, s-maxage=86400, stale-while-revalidate=3600 |
| 障害時の fallback あり | ... , stale-if-error=86400 |
CDN ベンダ間の挙動差
主要 CDN の Cache-Control 解釈:
| ディレクティブ | Cloudflare | Fastly | CloudFront | Akamai |
|---|---|---|---|---|
s-maxage | ✓ | ✓ | ✓ | ✓ |
stale-while-revalidate | ✓ | ✓ | △(Lambda@Edge 必要) | ✓ |
stale-if-error | ✓ | ✓ | ✗ | ✓ |
immutable | ✓ | ✓ | △(ブラウザ向けのみ) | ✓ |
private の解釈 | 共有非保存 | 共有非保存 | 共有非保存 | 共有非保存 |
CloudFront はやや弱め。stale-while-revalidate を使うなら他 CDN を検討するか、Lambda@Edge / CloudFront Functions で代替実装。
落とし穴
1. Vary ヘッダの欠落
Cache-Control: public, max-age=3600
Content-Encoding: gzip これだと Accept-Encoding: gzip も identity も同じ URL でキャッシュされ、非対応ブラウザに gzip が送られて壊れる可能性。Vary: Accept-Encoding を必ず併記。
2. クエリ文字列の扱い
?v=123 のようなバージョン識別子を URL に含める運用は、CDN によってクエリ文字列をキャッシュキーに含めるか判定が違います。Cloudflare は標準で含める、CloudFront は設定が要る。
3. Expires だけ書いて Cache-Control を忘れる
古いキャッシュサーバ向けに Expires を残す現場もあるが、Cache-Control がないと共有キャッシュは保守的に振る舞い、デフォルトで保存しないことがあります。両方書くか、Cache-Control だけにする。
まとめ
Cache-Control のディレクティブは 「保存可否 × 新鮮さ × 再検証戦略 × stale 配信」 の組合せで挙動が決まります。「max-age=3600 だけ付けておけばよい」は静的アセットには十分かもしれませんが、API レスポンスやログイン後の HTML には不適切。用途別の早見表を出発点に、CDN 側の s-maxage・ブラウザ側の max-age・stale 配信の stale-while-revalidate を意識的に組み合わせるのが現代的な書き方です。
HTTP ステータスコードの確認・分類は HTTP ステータスコードツール で行えます。