Cache-Control入門【GIFで超分かりやすく】
「これを見ればCache-Control周りが大体分かる」を目指します。
Cache-Controlヘッダーとは?
Cache-Controlは、ブラウザ・CDN・サーバーの間で「キャッシュをどう扱うか?」を伝えるためのHTTPヘッダーです。
Cache-Controlにはさまざまなディレクティブ(指示)があり、それらを組み合わせることでキャッシュの挙動を細かく制御できます。
ここからは、レスポンスヘッダーとしてのCache-Controlの各ディレクティブについて説明していきます。
「fresh」や「stale」とは?
まず、キャッシュの状態を表す用語を説明します。
- fresh:「このキャッシュを勝手に使っていいですよ」という状態
- stale:「このキャッシュは古いので、使う前にサーバーに確認してください」という状態
ただし、サーバーに確認せずにstaleなキャッシュが使われることがあります。
詳しくはmust-revalidateやstale-while-revalidateの項を見てください。
max-age
「◯秒間はfreshだよ」の指示です👇️

指定した秒数を過ぎると、staleになります。
s-maxage
max-ageのCDN限定版です。ブラウザには効きません。
max-ageと組み合わせると、ブラウザとCDNに別々の秒数を指示できます👇️

public
「キャッシュしていいよ」の指示です👇️

通常publicを明示する必要はありません。なぜならmax-ageなどの明示的なディレクティブが付いている場合、デフォルトでキャッシュ可能だからです。
ただしpublicには、通常キャッシュされないレスポンスもキャッシュ可能にする効果もあります。ややこしい話ですし、大半のケースでは理解しなくても困らないので、とりあえず「publicは基本指定しなくていい」という理解で問題ないと思います。気になる方は以下の折りたたみを参照してください。
本来キャッシュされないレスポンスをキャッシュ可能にするディレクティブ
① Authorizationヘッダー付きリクエストへのレスポンス
Authorizationヘッダー付きリクエストへのレスポンスは、デフォルトだとCDNはキャッシュできません(RFC 9111 Section 3.5)。
しかし、以下のディレクティブを指定するとキャッシュできるようになります。
publics-maxagemust-revalidate
② 特定のステータスコード(302、500、503など)
特定のステータスコード(302、500、503など)を持つレスポンスは、デフォルトだとブラウザもCDNもキャッシュできません。
しかし、以下のディレクティブを指定するとそれぞれキャッシュできるようになります。
| ディレクティブ | ブラウザ | CDN |
|---|---|---|
public |
✅ | ✅ |
private |
✅ | ❌ |
s-maxage |
❌ | ✅ |
private
「CDNはキャッシュするな。ブラウザだけキャッシュしていいよ」の指示です👇️

no-cache
「キャッシュしてもいいけど、使う前に必ずサーバーに確認してね」の指示です👇️

名前に反して「キャッシュするな」という意味ではないので注意です。
no-store
「キャッシュするな」の指示です👇️

CDNもブラウザもキャッシュを保存しません。
must-revalidate
「staleになったら、必ずサーバーに確認してから使ってね」の指示です。
通常、サーバーに接続できない場合などに「とりあえずstaleなキャッシュを使う」という動作が許容されています。
must-revalidateを指定すると、この動作を禁止できます。
ちなみに以下は等価です。
Cache-Control: no-cache
Cache-Control: max-age=0,must-revalidate
proxy-revalidate
must-revalidateのCDN限定版です。ブラウザには効きません。
must-understand
「ブラウザやCDNがそのステータスコードのキャッシュ要件を正しく理解している場合のみキャッシュしてよい」という指示です。
現時点では使う場面はありません。
詳しく知りたい方は、以下の記事が参考になると思います。
immutable
「このリソースは絶対に変わらないから、再検証も不要だよ」の指示です。
通常、ブラウザはページをリロードすると「リロードってページを新しくしたいから行うんだよね?じゃあfreshなキャッシュでも念のためサーバーに確認した方がいいのでは?」と判断してリクエストを送ることがあります。
immutableを指定すると、このリクエストを省略できます。
no-transform
「レスポンスを勝手に変換するな」の指示です。
CDNは、パフォーマンス向上のためにレスポンスを勝手に変換することがあります。たとえばJPEG画像の場合、CDNが圧縮して画質を下げたりWebPに変換したりすることがあります👇️

no-transformを指定すると、この変換を禁止できます。
stale-while-revalidate
「staleになってから◯秒間は、すぐ返して裏で更新しておいてね」の指示です👇️
▲この例では、staleになってから5秒間はstaleなキャッシュを即座に返しつつ、バックグラウンドで再検証できます。
指定した秒数を超えると、通常のstaleと同じ動作になります👇️

stale-if-error
「サーバーがダウンしても、◯秒間はstaleなキャッシュを返してね」の指示です👇️
▲この例の場合、サーバーが500エラーやタイムアウトを返した場合でも、staleになってから5秒以内なら古いキャッシュをユーザーに返せます。
指定した秒数を超えると、エラーがそのままユーザーに返されます👇️

Varyヘッダーとは?
「このヘッダーの値が違ったら、別のキャッシュとして扱ってね」ができるHTTPヘッダーです。
たとえばVary: Accept-Languageを指定すると、同じURLでもリクエストのAccept-Languageヘッダーが違えば別々にキャッシュされます👇️

キャッシュを識別するための値をキャッシュキーと呼びます。通常はURLがキャッシュキーになりますが、Varyを使うと「URL + 指定したヘッダーの値」をキャッシュキーにできるということです。
ただし、実際はCDNによってはVaryで指定できるヘッダーが制限されています。たとえばAkamaiではデフォルトでAccept-Encoding以外は無視されます。
それ以外のヘッダーで分岐したい場合は、CDN独自のキャッシュキー設定機能を使う必要があります。
「URLクエリがキャッシュキーに含まれるか」はCDNによって違う
たとえばCloudflareはデフォルトでURLクエリを含めますが、AWS CloudFrontはデフォルトで含めません。
これは結構大きな違いだと思うので、自分の使っているCDNの挙動を調べておいたほうが良さそうです。
キャッシュの再検証はどう行われるのか?
staleになったキャッシュの再検証は、以下のヘッダーで行えます。
| レスポンスヘッダー | リクエストヘッダー | 判定方法 |
|---|---|---|
| ETag | If-None-Match | 内容が同じか? |
| Last-Modified | If-Modified-Since | 更新日時が同じか? |
ETag ↔ If-None-Match
ETagは「内容が同じか?」を判定するためのIDです。
サーバーがレスポンスにETag: "abc123"のような値を付けて返します。
キャッシュがstaleになったとき、ブラウザはIf-None-Match: "abc123"というヘッダーを付けてサーバーに問い合わせます。サーバーは「今のETagと同じ?」を確認し、同じなら「変わってないよ」という意味の304レスポンスを返します👇️

304レスポンスにはボディがないので、データ転送量とレスポンス時間を節約できます。
Last-Modified ↔ If-Modified-Since
Last-Modifiedは「更新日時が同じか?」を判定するためのヘッダーです。
サーバーがレスポンスにLast-Modified: Wed, 01 Jan 2025 00:00:00 GMTのような値を付けて返します。
キャッシュがstaleになったとき、ブラウザはIf-Modified-Sinceヘッダーを付けて問い合わせます。サーバーは「この日時以降に更新された?」を確認し、更新されていなければ304を返します👇️

キャッシュの期間を指定していない場合の挙動は?
ここまでで、Cache-Controlで指定できる主なディレクティブ(指示のこと)について説明しました。
ただ、実はまだ説明していない重要なことがあります。
それは以下の2通りの場合はどうなるのか?ということです。
- Cache-Controlを指定していない場合
- Cache-Controlを指定しているが、「自分への指示がないな?」状態が起こるとき
前者はそのままの意味です。
後者は、たとえば以下のGIFのようなケースです。

s-maxageはCDN専用のディレクティブなので、ブラウザは無視します。
なのでブラウザからすると「自分への指示がないな?」状態になっています。
この場合どうなるの?という話です。
ブラウザの場合 → ヒューリスティックキャッシュ
ブラウザの場合「何も指示されていない場合はなるべくキャッシュしたろ!」という動きをとります。
このとき保存されるキャッシュのことを「ヒューリスティックキャッシュ」といいます。
具体的にいうと、「Last-Modifiedヘッダーがあれば、その経過時間の10%程度をキャッシュ期間とする」という動きになります。たとえば、「1年前に更新されたリソースなら約1ヶ月キャッシュする」という動きになります。
便利そうに見えますが、ヒューリスティックキャッシュに頼るのは避けるべきです。開発者の意図しない動作になる可能性があるためです。
ですので、Cache-Controlは明示的に指定しましょう。「よくわからんけど動いてる」は事故のもとです。
CDNの場合 → 業者によって違う
CDNの場合、CDN業者によってルールがかなり違います。
たとえば、Cloudflareだと以下のようなルールでキャッシュされます。
- 拡張子が静的ファイルだったら勝手にキャッシュする
- キャッシュする時間はステータスコードで決める(例:200だったら2時間)
それに対して、たとえばAWS CloudFrontだと全ファイルを決められたルールでキャッシュします。
CloudFrontは何も指定しなければ「CachingOptimized」というルールが指定されますが、このルールだとどんなファイルでもデフォルトで24時間キャッシュします。なのでHTMLファイルでさえも24時間キャッシュされます。
このようにCDN業者によって挙動が違います。なのでブラウザと同じくCache-Controlは明示的に指定すべきです。指定しておけばどのCDNでも同じように動くはずだからです。
ブラウザをリロードしたときの挙動は?
ここまではレスポンスヘッダーとしてのCache-Controlを説明してきました。
しかし、Cache-Controlはリクエストヘッダーとしても使えます。
それぞれ以下のような違いです。
| 誰から | 誰への指示か | 従う義務 | |
|---|---|---|---|
| レスポンスのCache-Control | サーバー | ブラウザとCDN | あり(従わないといけない) |
| リクエストのCache-Control | ブラウザ | ブラウザとCDN | なし(無視できる) |
では、リクエストのCache-Controlはいつ使われるのかというと、代表的なのがブラウザのリロード時です。
たとえばChromeだと、以下のような感じになります。
- リロード(F5) →
Cache-Control: max-age=0 - スーパーリロード(Ctrl+F5) →
Cache-Control: no-cache
要するに、リロードした場合は必ずサーバーへの確認が走るということです。
ただし、一般的にCDNはリクエストヘッダーのCache-Controlを無視することが多いので、結果的にブラウザのローカルキャッシュにしか効かないことが多いです。Cloudflareのこのページなどに書いてますが、クライアントが自由にキャッシュを無効化できてしまうと、オリジンサーバーへのDoS攻撃に悪用される可能性があるためだと思われます。
補足:Firefoxの場合
ちなみにFirefox(146.0.1)では、以下のような動作でした。
- リロード(F5) →
Cache-Controlを送らない - スーパーリロード(Ctrl+Shift+R) →
Cache-Control: no-cache
ですので、Firefoxの場合はリロードしてもキャッシュがfreshならリクエストが飛ばないようです
事故事例:メルカリで発生したCache-Controlの設定ミス
勉強になるので、メルカリが公開してくださっている情報漏えいの事例を紹介します。
結論からいうと、この事故は以下が原因で起こったようです。
- 本来は
privateかno-storeを設定しないといけなかったのにno-cacheを設定してしまった
なぜ漏洩したのか?
この事故には2つの要因があります。
要因①:no-cacheはCDNへのキャッシュ保存を禁止しない
no-cacheは「キャッシュするな」という意味ではありません。
「キャッシュを使うなら事前にサーバーに確認してね」という意味です。
つまり、CDNはキャッシュを保存できてしまいます。
要因②:Request Collapsingによるレスポンスの使い回し
ほぼ同時に複数のユーザーからリクエストを受けた場合、CDNはRequest Collapsing[1]という最適化を行うことがあります。これは「同じURLへのリクエストが同時に複数来たら、サーバーへは1回だけ問い合わせて、その結果を全員に返す」という仕組みです。
この2つの要因が合わさったことにより、以下のような動きになってしまったようです👇️
▲😭の情報が🤨に漏洩してしまった
要するにCDNは「キャッシュを使い回すなとは言われてないし、同じタイミングのリクエストをわざわざ別々に聞きに行く意味ないやろ」と判断するわけです。
privateを設定すれば防げた
privateを設定すると、CDNはキャッシュを保存しません。
なので、各ユーザーのリクエストは個別にサーバーへ届き、それぞれ正しい情報が返されます👇️
▲privateならそれぞれのユーザーに正しい情報が返る
Zennのキャッシュ戦略を見てみる
せっかくなので、ZennでどのようなCache-Controlが設定されているのか見てみましょう(2026年2月現在)。
トップページ
Cache-Control: public, max-age=0, s-maxage=900
これは「CDNは15分間キャッシュしていいよ。ブラウザは即座にstaleとして扱ってね」という指示です。must-revalidateが付いていないのでno-cacheよりは少し緩いですが、実用上はほぼ毎回サーバーに確認が走ります。
トップページは新着記事やトレンドが表示されるので、ある程度の鮮度が求められます。とはいえアクセスが多いページなので、毎回オリジンに問い合わせるとサーバー負荷が高くなります。CDNに15分間キャッシュさせることで、サーバー負荷を軽減しつつ適度な鮮度を保っているのだと思います。
個別記事ページ
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
これは要するに「一切キャッシュするな」という指示です。
本来はno-storeだけで十分なはずですが、念のため複数のディレクティブを組み合わせているのかもしれません。
もしくは、ZennはNext.jsを使っているはずなので、Next.jsがデフォルトで設定しているのかもしれません。
個別記事にある筆者のプロフィール画像
Cache-Control: public, max-age=3600
これは「1時間まではキャッシュしていいよ」という指示です。
プロフィール画像のURLはstorage.googleapis.com(Google Cloud Storage)なので、このCache-ControlはZennがGCSにアップロードする際にメタデータとして設定しているものと思われます。
⋯と思ったのですが、調べてみるとGCSは公開オブジェクトにCache-Controlメタデータが設定されていない場合、デフォルトでpublic, max-age=3600を返すようです。なのでZennが明示的に設定しているのではなく、GCSのデフォルト設定なのかもしれません。
JSファイル、CSSファイル
Cache-Control: public, max-age=31536000, immutable
これは「1年間はキャッシュしていいよ。再検証もしなくていいよ」という指示です。
ZennのJSファイルはファイル名にハッシュ値が含まれています(例:app-abc123.js)。内容が変わればファイル名も変わるので、長期間キャッシュしても古いファイルが使われる心配がありません。
また、immutableを指定することで、リロード時の無駄な再検証リクエストも省略しているようです。
「衆議院議員選挙2026の開票速報サイト」のキャッシュ戦略を見てみる
開票速報サイトは、リアルタイムに更新されるサイトです。しかも当日は相当なアクセスが予想されます。
▲日経新聞の開票速報サイト
そういうページでは、どういうキャッシュ戦略を採っているのか見てみます。
各社のCache-Controlの値と、DNSのCNAMEチェーンやレスポンスヘッダーから判明したCDNをまとめると以下のようになっていました。
| メディア | Cache-Control | CDN |
|---|---|---|
| NHK | max-age=27 |
Akamai |
| 朝日新聞 | max-age=0, no-cache |
Akamai |
| 日経新聞 | no-cache |
Fastly |
| 読売新聞 | no-store, no-cache, must-revalidate, proxy-revalidate |
Fastly |
CDNはAkamai派とFastly派に二分されていました。
読売新聞以外はキャッシュしていますが、再検証なしでfreshなキャッシュを返せるのはNHKだけです。しかもNHKも謎に27秒(理由は後述)だけです。
大量にアクセスが来るはずなのに色々謎ですよね。なので調べてみました。
CDNがオリジンのCache-Controlを無視している
冒頭の表のCache-Controlはオリジンが返した値ですが、実はCDN側がそれに従う義務はありません。
たとえば読売新聞のレスポンスヘッダーをよく見ると、矛盾したことが起こっています。
cache-control: no-store, no-cache, must-revalidate, proxy-revalidate
x-cache: MISS, HIT, HIT
age: 133
no-store(キャッシュするな!)と指示しているのに、x-cache: HIT(キャッシュから返してるよ)でage: 133(133秒前のキャッシュだよ)になっています。
これはFastlyがオリジンのCache-Controlを無視してキャッシュしていることを示しています。
このように、大規模なサイトだとCache-Controlに頼らずにCDN側で独自のキャッシュ設定をしていることがあります。なので、Cache-Controlの値だけを見てもキャッシュの全体像は分からないということですね。
ちなみに私の好きなCloudflareでも、「オリジンの設定を無視してキャッシュせよ」の設定があります

Cloudflareは無料で色々な設定をいじれます。Cache-Controlの動きを試したい場合はオススメです。
NHKのmax-age=27の謎
NHKのmax-age=27という中途半端な数字が気になったので、調べてみたのですが
5秒おきにリクエストを送ってみるとmax-ageが5ずつ減り、0になると60にリセットされることを発見しました。要するに、実際のキャッシュの寿命は60秒で、max-age=27はたまたまチェックした瞬間の残り秒数だったようです。
通常はmax-age=60とage=33のように経過時間を別ヘッダーで伝えますが、NHK(Akamai)はageを返さず、max-age自体を残り寿命として書き換えているようです。
まとめ
-
Cache-Controlは明示的に指定する。ヒューリスティックキャッシュに頼らない - 個人情報を含むレスポンスには
privateかno-storeを設定する -
no-cacheは「キャッシュするな」ではない。間違えると事故になる - CDNはオリジンの
Cache-Controlを無視することがある。CDN側の設定も確認する
\ PR /
弊社ではエンジニアを募集しております。
具体的に言うと、この記事のような技術記事を興味津々で読んでくれるような人を募集しております。
そうです、あなたのことです。お待ちしております。
→Webエンジニアの募集要項 | PrAha Entrance Book
-
Request Coalescingとも呼ばれます。RFC 9111 Section 4で認められている動作です。 ↩︎
Discussion