- Published on
HTTPキャッシングを正しく使う — Cache-Control、ETag、stale-while-revalidateの正確な意味
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — デプロイしたのにユーザーは古いバンドルを見ている
デプロイは終わり、サーバーには新しいファイルが上がっていて、シークレットウィンドウでは新しい画面が出ます。ところが一部のユーザーは相変わらず古い画面を見ています。強制リロードを案内すれば解決しますが、その案内が必要になった時点で、すでにキャッシング設計が間違っているということです。
HTTPキャッシングは指示子をいくつか暗記する問題ではなく、どの層が何をどれだけ長く持っていて、それをどうやって取り消せるのかを知る問題です。そして取り消せない層がひとつあります。ユーザーのブラウザです。
Cache-Control指示子の正確な意味
最も誤解される指示子から整理します。
| 指示子 | 正確な意味 | よくある誤解 |
|---|---|---|
| max-age=N | レスポンス生成時点からN秒間は新鮮。新鮮なあいだはオリジンに問い合わせない | ブラウザがN秒ごとに確認すると思っている |
| s-maxage=N | 共有キャッシュにだけ適用され、そこではmax-ageを上書きする | ブラウザにも適用されると思っている |
| no-cache | 保存はするが、再利用の前に必ずオリジンで検証 | キャッシュするなという意味だと理解 |
| no-store | どのキャッシュにも保存禁止。ディスクにも残さない | no-cacheと同じものだと理解 |
| must-revalidate | 鮮度が切れた後は検証なしで提供禁止。エラー時のstale提供も禁止 | つねに検証しろという意味だと理解 |
| private | ブラウザなど単一ユーザーのキャッシュにだけ保存可能 | 暗号化やアクセス制御だと理解 |
| public | ふだんなら保存されないレスポンスも共有キャッシュに保存可能だと示す | キャッシングを入れるスイッチだと理解 |
| immutable | 新鮮なあいだはリロードでも再検証しない | 永遠にキャッシュされると理解 |
| stale-while-revalidate=N | 期限切れ後N秒間はstaleレスポンスを即座に返しつつバックグラウンドで更新 | max-ageを伸ばすのと同じだと理解 |
no-cacheとno-storeの違いが決定的です。ログイン済みユーザーの口座ページにno-cacheを付けると、レスポンスはディスクに保存されます。共用PCで戻るボタンを押したり、キャッシュディレクトリを漁ったりすれば内容が残っています。保存そのものを防ぐにはno-storeでなければなりません。
逆によく見るのがこの組み合わせです。
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache
Expires: 0
no-storeがあれば保存されないので残りは意味がありません。must-revalidateは保存されたレスポンスに対する規則であり、PragmaはHTTP/1.0時代のリクエストヘッダーなので、レスポンスに付けても現代のキャッシュは見ません。慣行のようにコピーされて回っていますが、Cache-Control: no-storeの一行で十分です。
もうひとつ知っておくべきなのは、キャッシュヘッダーを何も送らないことがキャッシュ禁止を意味しないという点です。明示的な鮮度情報がなければ、キャッシュはヒューリスティックに寿命を推定でき、慣行としてLast-Modifiedからの経過時間の10パーセント程度を使います。ひと月前に更新されたファイルなら3日ほどキャッシュされうるという意味です。「キャッシュ設定をしていないからキャッシュされないだろう」という仮定は成立しません。
条件付きリクエストと304 — 節約するものと節約しないもの
鮮度が切れたレスポンスをキャッシュが捨てるわけではありません。検証子を持ってオリジンに問い合わせます。
GET /assets/logo.svg HTTP/1.1
Host: cdn.example.com
If-None-Match: "8f3c1a20-4e2"
If-Modified-Since: Wed, 09 Jul 2026 11:20:14 GMT
HTTP/1.1 304 Not Modified
ETag: "8f3c1a20-4e2"
Cache-Control: max-age=600
Date: Sun, 26 Jul 2026 04:20:11 GMT
本文がありません。12KBのSVGを受け取り直さなかったので帯域を節約しました。
ここが重要な地点です。304は帯域を節約しますが往復時間はそのままかかります。往復遅延が180ミリ秒のモバイル回線でアセット30個を検証すると、何も変わっていないのにかなりの時間を使います。HTTP/2で多重化されて並列に処理されても、最低一度の往復は残ります。
だから条件付きリクエストは目的が違います。頻繁に変わるリソースで転送量を減らすのには向いていますが、静的アセットのロード時間を減らすには不十分です。静的アセットはリクエストそのものをなくすべきです。
ETagを使うときに実務で引っかかる問題もあります。複数台のサーバーが同じファイルを配信しているのにETagがサーバーごとに違うと、ユーザーが別のノードにルーティングされるたびに304ではなく200が返ってきます。ファイルのinode番号をETagの計算に含める既定設定がこの問題を引き起こします。内容ハッシュベースに変えるか、その要素を外す必要があります。
圧縮も影響します。レスポンスをgzipやbrotliでエンコードするとバイト列が変わるので、強いETagをそのまま使ってはいけません。弱いETagを使うかエンコードごとに値を分ける必要があり、Vary: Accept-Encodingも併せて必要です。
Last-Modifiedには秒単位の解像度という限界があります。同じ秒に二度変わると区別できません。両方送れるなら送りつつ、正確さが必要ならETagが優先です。
ハッシュ入りファイル名とimmutable — フロントエンドデプロイの標準的な組み合わせ
バンドラーがapp.4f2a1c9e.jsのようなファイル名を作る理由がここにあります。内容が変わればハッシュが変わり、ハッシュが変わればURLが変わります。URLが違えばキャッシュキーが違うので、古いキャッシュと衝突する余地がありません。
ということは、このファイルは永遠にキャッシュしても安全です。
# ハッシュの入った静的アセット
Cache-Control: public, max-age=31536000, immutable
# エントリポイントのHTML
Cache-Control: no-cache
immutableがないと、ユーザーがリロードを押したときブラウザは新鮮なアセットに対しても条件付きリクエストを送ります。アセットが40個ならリロードのたびに304が40往復します。immutableはその再検証まで省略させます。
この組み合わせの核心は、無効化の対象がきっちりひとつに減ることです。HTMLだけを毎回検証し、HTMLが参照するアセットのURLが変われば新しいアセットが自動的に取得されます。キャッシュ無効化が難しいという問題を、無効化しない設計に置き換えたわけです。
注意点が二つあります。HTMLに長いmax-ageを付けるとこの構造が丸ごと崩れます。ユーザーが古いHTMLを持っていると古いアセットURLを参照し続けるからです。そしてデプロイのときに古いアセットファイルをすぐ消すと、古いHTMLを持っているユーザーが404に出会います。直前の数バージョンぶんのアセットは残しておく必要があります。
三つのキャッシュ層と互いに異なる無効化の方法
同じレスポンスが複数の場所に同時に保存されます。各層は制御の手段が違います。
ブラウザキャッシュはユーザーの端末にあります。ここには無効化の命令を送る方法がありません。一度max-ageを1年にして送り出したURLは、そのユーザーがキャッシュを消すまで取り消せません。だからブラウザに送り出すTTLは、取り消す必要がないと確信できる場合にだけ長く取ります。ハッシュ入りファイル名がその確信を作ってくれます。
CDNキャッシュは私たちの制御下にあります。パージAPIでURLやタグ単位に消せますし、大半のCDNがサロゲートキーやキャッシュタグをサポートしています。商品ひとつが変わればその商品タグの付いたすべてのページを一度に消す、という具合です。ただし世界中のエッジに反映されるまで数秒から数十秒かかります。
リバースプロキシキャッシュは自分たちのインフラの中にあります。nginxのキャッシュパージ、Varnishのbanやpurgeで消します。正規表現で範囲を指定できるので柔軟ですが、広い範囲を一度に消すとオリジンにリクエストが殺到します。
四つめの層がもうひとつあるのに、よく忘れられます。サービスワーカーのキャッシュです。これはCache-Controlに従わず、私たちが書いたコードが決めたとおりに動きます。デプロイしたのに特定のユーザーだけがずっと古い画面を見ているなら、まずサービスワーカーを疑うべきです。
層ごとに違うTTLを与えたい場面は多いです。ブラウザには短く、CDNには長く与えれば、デプロイ時にパージで即座に反映しつつ、オリジンの負荷は低く保てます。
Cache-Control: public, max-age=60
CDN-Cache-Control: public, max-age=86400
CDN-Cache-ControlはCDNだけが解釈し、ブラウザは無視します。s-maxageでも似た効果を出せますが、その値はすべての共有キャッシュに適用されるので、会社のプロキシのような中間キャッシュにも一緒に掛かるという違いがあります。
Varyとキャッシュキーの爆発
キャッシュキーは基本的にメソッドとURLです。同じURLなのにリクエストヘッダーによってレスポンスが変わるなら、その事実を知らせる必要があります。
Vary: Accept-Encoding, Origin
これは必須です。gzipレスポンスが圧縮に対応していないクライアントへ出ていったり、CORSヘッダーが別オリジン用のまま保存されて再利用されたりする事故がここから生まれます。
問題はVaryに何を入れるかです。列挙したヘッダーの値の組み合わせごとに別々のキャッシュ項目ができるからです。
Vary: User-Agentを付けると事実上キャッシュが切れます。ブラウザのバージョンとOSの組み合わせのぶんだけ項目ができ、実際のトラフィックではその種類が数万通りです。ヒット率がゼロに収束します。端末の種類によって別のレスポンスが必要なら、エッジでUAをモバイルとデスクトップのような少数の値に正規化したうえで、その値を基準に分ける必要があります。
Vary: Cookieはもっと危険です。分析ツールが仕込んだクッキーがひとつあるだけでユーザーごとに値が変わるので、項目がユーザー数ぶん生まれます。キャッシュが切れるだけならまだしも、保存領域が爆発して他のコンテンツまで押し出されます。キャッシュキーに入れるクッキーだけをホワイトリストで指定する機能が大半のCDNにあるので、それを使ってください。
Vary: Accept-Languageも言語ネゴシエーションのヘッダーの組み合わせが多様なので、思ったより項目がたくさんできます。パスに言語を入れてURLで区別するほうがキャッシュに優しいです。
stale-while-revalidateとAPIレスポンスキャッシングの落とし穴
期限切れのキャッシュに出会うと、ユーザーはオリジンのレスポンスを待たなければなりません。人気のあるページのキャッシュが切れた瞬間に、同時に入ってきたリクエストが全部オリジンへ行ってしまう問題もあります。
Cache-Control: public, max-age=60, stale-while-revalidate=600, stale-if-error=86400
60秒間は新鮮です。そのあとの600秒間はキャッシュされたレスポンスを即座に返しながら、裏で新しいレスポンスを受け取って保存します。ユーザーから見ればつねにキャッシュヒットの速度であり、コンテンツはせいぜい60秒あまり古いだけです。stale-if-errorはオリジンが5xxを返したり到達不能だったりするときに、一日のあいだ古いレスポンスでも返します。障害時間をユーザーから隠す安上がりな方法です。
この組み合わせは一覧ページ、ホーム画面、公開APIの読み取りレスポンスのように、秒単位の鮮度が不要な場所によく合います。逆に在庫数量や残高のように、古い値がそのまま誤りになるデータには使ってはいけません。
認証済みのレスポンスを共有キャッシュに流す事故
APIレスポンスのキャッシングで最大の事故は性能ではなくデータ露出です。
Authorizationヘッダーが載ったリクエストのレスポンスは、共有キャッシュが保存しないのが原則です。明示的にpublicやs-maxageを付けないかぎりそうです。ところがクッキーで認証するリクエストにはこの保護が適用されません。クッキーベースでログインするサイトがユーザー別のHTMLにCache-Control: max-age=300を付けると、CDNがユーザーAのページを保存してユーザーBにそのまま返します。実際に複数のサービスで発生していて、原因の把握が遅れやすい類型です。
防御は単純です。ユーザー別のレスポンスには必ずprivateを付け、機微なものはno-storeにします。
# ユーザー別のレスポンス
Cache-Control: private, no-store
# 公開の一覧レスポンス
Cache-Control: public, max-age=30, stale-while-revalidate=300
もっとよい方法は、そもそも経路を分けることです。公開データとユーザー別データを別のエンドポイントに分ければ、公開側は安心してCDNに載せ、ユーザー別側はキャッシュを切ればよくなります。ひとつのレスポンスに両方を混ぜると、全体を最も保守的なポリシーに合わせる必要が出てきます。
APIにETagを付けてクライアントのポーリング費用を減らすのも有用ですが、条件付きリクエストが来ればサーバーは依然としてETagを計算しなければなりません。更新時刻カラムやバージョンカウンターのように安く得られる根拠があるときにだけ実益があります。レスポンス全体を作ってハッシュを取ってから304を返すのでは、帯域だけ節約してサーバー負荷はそのままです。
おわりに — 無効化が難しいなら無効化しない設計へ
キャッシュ無効化が難しいという言葉は誇張ではありません。ブラウザキャッシュには命令を送れず、CDNのパージは伝播に時間がかかり、層ごとに方法が違い、サービスワーカーは規則に従いません。だからよくできたシステムは、無効化を精巧にする代わりに無効化する機会を減らします。
内容が変わればURLが変わるようにして、そのURLは永遠にキャッシュします。変わるものは短く検証するエントリポイントひとつに集めます。秒単位の鮮度が不要なものはstale-while-revalidateで遅延なく配信します。ユーザー別のデータは経路から分けて、キャッシュポリシーが混ざらないようにします。
キャッシング設計でまず決めるべきなのはTTLの数字ではなく、このレスポンスを後で取り消す可能性があるかどうかです。取り消すことがないと確信できる構造を作れば、残りの設定はおのずと付いてきます。