🥦

Cache-Control入門【GIFで超分かりやすく】

に公開

「これを見ればCache-Control周りが大体分かる」を目指します。

Cache-Controlヘッダーとは?

Cache-Controlは、ブラウザ・CDN・サーバーの間で「キャッシュをどう扱うか?」を伝えるためのHTTPヘッダーです。

Cache-Controlにはさまざまなディレクティブ(指示)があり、それらを組み合わせることでキャッシュの挙動を細かく制御できます。

ここからは、レスポンスヘッダーとしてのCache-Controlの各ディレクティブについて説明していきます。

「fresh」や「stale」とは?

まず、キャッシュの状態を表す用語を説明します。

  • fresh:「このキャッシュを勝手に使っていいですよ」という状態
  • stale:「このキャッシュは古いので、使う前にサーバーに確認してください」という状態

ただし、サーバーに確認せずにstaleなキャッシュが使われることがあります。
詳しくはmust-revalidatestale-while-revalidateの項を見てください。

max-age

「◯秒間はfreshだよ」の指示です👇️

max-ageでキャッシュが保存され、5秒後にstaleになる様子を説明しているGIF動画

指定した秒数を過ぎると、staleになります。

s-maxage

max-ageのCDN限定版です。ブラウザには効きません。

max-ageと組み合わせると、ブラウザとCDNに別々の秒数を指示できます👇️

s-maxageとmax-ageでCDNとブラウザが別々のタイミングでstaleになる様子を説明しているGIF動画

public

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

publicでCDNとブラウザの両方にキャッシュが保存される様子を説明しているGIF動画

通常publicを明示する必要はありません。なぜならmax-ageなどの明示的なディレクティブが付いている場合、デフォルトでキャッシュ可能だからです。

ただしpublicには、通常キャッシュされないレスポンスもキャッシュ可能にする効果もあります。ややこしい話ですし、大半のケースでは理解しなくても困らないので、とりあえず「publicは基本指定しなくていい」という理解で問題ないと思います。気になる方は以下の折りたたみを参照してください。

本来キャッシュされないレスポンスをキャッシュ可能にするディレクティブ

Authorizationヘッダー付きリクエストへのレスポンス

Authorizationヘッダー付きリクエストへのレスポンスは、デフォルトだとCDNはキャッシュできません(RFC 9111 Section 3.5)。

しかし、以下のディレクティブを指定するとキャッシュできるようになります。

  • public
  • s-maxage
  • must-revalidate

② 特定のステータスコード(302500503など)

特定のステータスコード(302500503など)を持つレスポンスは、デフォルトだとブラウザもCDNもキャッシュできません。

しかし、以下のディレクティブを指定するとそれぞれキャッシュできるようになります。

ディレクティブ ブラウザ CDN
public
private
s-maxage

private

「CDNはキャッシュするな。ブラウザだけキャッシュしていいよ」の指示です👇️

privateでCDNにはキャッシュされず、ブラウザのみにキャッシュされる様子を説明しているGIF動画

no-cache

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

no-cacheでキャッシュが最初からstale状態で保存される様子を説明しているGIF動画

名前に反して「キャッシュするな」という意味ではないので注意です。

no-store

「キャッシュするな」の指示です👇️

no-storeでCDNにもブラウザにもキャッシュが保存されない様子を説明しているGIF動画

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に変換したりすることがあります👇️

CDNがJPEG画像をWebPに変換してしまう様子を説明しているGIF動画

no-transformを指定すると、この変換を禁止できます。

stale-while-revalidate

「staleになってから◯秒間は、すぐ返して裏で更新しておいてね」の指示です👇️

stale-while-revalidateでstaleなキャッシュを即座に返しつつ裏で再検証する様子を説明しているGIF動画▲この例では、staleになってから5秒間はstaleなキャッシュを即座に返しつつ、バックグラウンドで再検証できます。

指定した秒数を超えると、通常のstaleと同じ動作になります👇️

stale-while-revalidate期間を過ぎると通常のリクエストになる様子を説明しているGIF動画

stale-if-error

「サーバーがダウンしても、◯秒間はstaleなキャッシュを返してね」の指示です👇️

stale-if-errorでサーバーエラー時にstaleなキャッシュを返す様子を説明しているGIF動画▲この例の場合、サーバーが500エラーやタイムアウトを返した場合でも、staleになってから5秒以内なら古いキャッシュをユーザーに返せます。

指定した秒数を超えると、エラーがそのままユーザーに返されます👇️

stale-if-error期間を過ぎるとエラーがそのまま返される様子を説明しているGIF動画

Varyヘッダーとは?

「このヘッダーの値が違ったら、別のキャッシュとして扱ってね」ができるHTTPヘッダーです。

たとえばVary: Accept-Languageを指定すると、同じURLでもリクエストのAccept-Languageヘッダーが違えば別々にキャッシュされます👇️

Varyヘッダーで言語ごとに別々のキャッシュが保存される様子を説明しているGIF動画

キャッシュを識別するための値をキャッシュキーと呼びます。通常は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レスポンスを返します👇️

ETagとIf-None-Matchによる304レスポンスでキャッシュが再利用される様子を説明しているGIF動画

304レスポンスにはボディがないので、データ転送量とレスポンス時間を節約できます。

Last-Modified ↔ If-Modified-Since

Last-Modifiedは「更新日時が同じか?」を判定するためのヘッダーです。
サーバーがレスポンスにLast-Modified: Wed, 01 Jan 2025 00:00:00 GMTのような値を付けて返します。

キャッシュがstaleになったとき、ブラウザはIf-Modified-Sinceヘッダーを付けて問い合わせます。サーバーは「この日時以降に更新された?」を確認し、更新されていなければ304を返します👇️

Last-ModifiedとIf-Modified-Sinceによる304レスポンスでキャッシュが再利用される様子を説明しているGIF動画

キャッシュの期間を指定していない場合の挙動は?

ここまでで、Cache-Controlで指定できる主なディレクティブ(指示のこと)について説明しました。

ただ、実はまだ説明していない重要なことがあります。

それは以下の2通りの場合はどうなるのか?ということです。

  • Cache-Controlを指定していない場合
  • Cache-Controlを指定しているが、「自分への指示がないな?」状態が起こるとき

前者はそのままの意味です。

後者は、たとえば以下のGIFのようなケースです。

s-maxageのみ指定時にブラウザがヒューリスティックキャッシュになる様子を説明している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の設定ミス

勉強になるので、メルカリが公開してくださっている情報漏えいの事例を紹介します。

https://engineering.mercari.com/blog/entry/2017-06-22-204500/

結論からいうと、この事故は以下が原因で起こったようです。

  • 本来はprivateno-storeを設定しないといけなかったのにno-cacheを設定してしまった

なぜ漏洩したのか?

この事故には2つの要因があります。

要因①:no-cacheはCDNへのキャッシュ保存を禁止しない

no-cacheは「キャッシュするな」という意味ではありません。
「キャッシュを使うなら事前にサーバーに確認してね」という意味です。

つまり、CDNはキャッシュを保存できてしまいます。

要因②:Request Collapsingによるレスポンスの使い回し

ほぼ同時に複数のユーザーからリクエストを受けた場合、CDNはRequest Collapsing[1]という最適化を行うことがあります。これは「同じURLへのリクエストが同時に複数来たら、サーバーへは1回だけ問い合わせて、その結果を全員に返す」という仕組みです。

この2つの要因が合わさったことにより、以下のような動きになってしまったようです👇️

Request Collapsingで他のユーザーのデータが漏洩してしまう様子を説明しているGIF動画▲😭の情報が🤨に漏洩してしまった

要するにCDNは「キャッシュを使い回すなとは言われてないし、同じタイミングのリクエストをわざわざ別々に聞きに行く意味ないやろ」と判断するわけです。

privateを設定すれば防げた

privateを設定すると、CDNはキャッシュを保存しません。

なので、各ユーザーのリクエストは個別にサーバーへ届き、それぞれ正しい情報が返されます👇️

privateを設定すると各ユーザーに正しいデータが返される様子を説明しているGIF動画▲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を無視してキャッシュする」設定画面

Cloudflareは無料で色々な設定をいじれます。Cache-Controlの動きを試したい場合はオススメです。

NHKのmax-age=27の謎

NHKのmax-age=27という中途半端な数字が気になったので、調べてみたのですが

5秒おきにリクエストを送ってみるとmax-ageが5ずつ減り、0になると60にリセットされることを発見しました。要するに、実際のキャッシュの寿命は60秒で、max-age=27はたまたまチェックした瞬間の残り秒数だったようです。

通常はmax-age=60age=33のように経過時間を別ヘッダーで伝えますが、NHK(Akamai)はageを返さず、max-age自体を残り寿命として書き換えているようです。

まとめ

  • Cache-Controlは明示的に指定する。ヒューリスティックキャッシュに頼らない
  • 個人情報を含むレスポンスにはprivateno-storeを設定する
  • no-cacheは「キャッシュするな」ではない。間違えると事故になる
  • CDNはオリジンのCache-Controlを無視することがある。CDN側の設定も確認する

\  PR  /

弊社ではエンジニアを募集しております。

具体的に言うと、この記事のような技術記事を興味津々で読んでくれるような人を募集しております。

そうです、あなたのことです。お待ちしております。

Webエンジニアの募集要項 | PrAha Entrance Book

脚注
  1. Request Coalescingとも呼ばれます。RFC 9111 Section 4で認められている動作です。 ↩︎

PrAha

Discussion