レスポンスは 200 のはずなのに、なぜ通信量が 0 バイトになるのか?
Chrome の DevTools で Network タブを開くと、たまに Status が 304 で Size がほぼ 0 の行を見かける。Cache-Control や ETag というヘッダー名は知っていても、それが「キャッシュを使うか使わないか」の二択ではなく、2 段階の判断でできていることまで説明できるだろうか。
- HTTP キャッシュは「サーバーに聞かずに使ってよい期間(鮮度)」と「聞いてから使う判断(検証)」の 2 段構えでできている。
no-cacheは「キャッシュするな」ではなく「毎回サーバーに確認してから使え」という意味で、これを誤解すると意図しない挙動になる。- 検証リクエストが変更なしと分かると、サーバーは本文を送り返さず
304 Not Modifiedだけを返す。これが通信量 0 の正体である。
キャッシュには「鮮度」と「検証」という 2 つの層がある
Web アプリを書いていれば Cache-Control: max-age=3600 のようなヘッダーは見たことがあるはずだ。これは「レスポンスがどれだけの秒数、鮮度(fresh)を保つか」を示す値で、ブラウザはこの期間中はサーバーに一切問い合わせず、保存済みのレスポンスをそのまま使う。ここまでは直感通りだろう。
問題は鮮度が切れて古く(stale)なった後だ。キャッシュを丸ごと捨てて再取得するしかないように思える。しかし実際は、本文はまだ使えるかもしれないので、サーバーに「これ、まだ変わってない?」とだけ確認する検証(validation)という別の仕組みが用意されている。鮮度と検証は別レイヤーであり、この 2 段構えを分けて理解しないと、なぜ no-cache と no-store がまったく違う指示なのかが分からなくなる。
鮮度は「賞味期限まではパッケージを開けずに棚に置いておいてよい」であり、検証は「賞味期限は切れたが、店員に電話して『まだ入荷分と同じロットで大丈夫か』を確認してから棚に戻す」動作に近い。期限切れ=即廃棄ではなく、電話 1 本(=軽いリクエスト)で済むなら中身(=レスポンスボディ)を運び直す必要はない。
no-cache は「キャッシュするな」ではない
MDN の Cache-Control リファレンスが明確に注意しているように、no-cache は保存自体は許可しつつ、再利用の前に必ずサーバーへ検証リクエストを送らせる指示である。名前から「キャッシュ禁止」と誤解しがちだが、保存を完全に禁止するのは no-store の役目だ。この 2 つを混同すると、「no-cache を付けたのに Network タブにキャッシュされたエントリが残っている」ように見えて混乱する。
Cache-Control: no-cache # 保存はする。使う前に必ず確認する Cache-Control: no-store # 保存自体を禁止する(個人情報など) Cache-Control: max-age=3600 # 3600 秒はサーバーに聞かずに使う Cache-Control: public, max-age=31536000, immutable # CDN含め共有可・1年間・検証も不要
immutable を付けると、鮮度が切れていなくても発生しうる「リロード時の強制検証」まで省略できる。ハッシュ付きファイル名(bundle.abc123.js)のように内容が変わればURL自体が変わる静的アセットに向く指示で、HTML のような同一 URL を使い回すリソースには使えない。
検証の実体は ETag と If-None-Match の往復
検証には主に 2 つの方式がある。Last-Modified/If-Modified-Since は更新日時(秒単位)で比較する古い方式、ETag/If-None-Match はサーバーが生成したハッシュ値などの識別子で比較する方式だ。両方が使える場合、If-None-Match による比較が優先される。時刻の分解能では見分けられない短時間の更新も検出できるためである。
1回目のリクエスト client server │ GET /style.css │ │─────────────────────────▶│ │ 200 OK │ │ ETag: "33a64df5" │ │◀─────────────────────────│ │ (本文を保存) │ 鮮度切れ後、2回目のリクエスト │ GET /style.css │ │ If-None-Match:"33a64df5"│ │─────────────────────────▶│ │ 304 Not Modified │ │ (本文なし) │ │◀─────────────────────────│ │ (保存済みの本文を再利用) │
サーバー側は受け取った If-None-Match の値と現在の ETag を比較し、一致すれば本文を作り直さず 304 Not Modified だけを返す。ヘッダーだけの応答になるため、DevTools 上では Status 304・Size がほぼ 0 バイトのリクエストとして観測できる。これは「キャッシュを使わなかった」のではなく、「キャッシュを使ってよいと確認が取れた」結果である。
HTML と静的アセットで戦略を変える理由
静的アセット(バンドル済み JS/CSS/画像)はファイル名にハッシュを含めれば URL 自体がコンテンツを表すため、public, max-age=31536000, immutable のように長期間かつ無条件でキャッシュしてよい。内容が変わればビルドが別 URL を生成するので、古いキャッシュが混ざる心配がない。
一方 HTML は URL を変えられない(/index.html は常に同じアドレス)ため、長い max-age を付けると更新が届かなくなる。そこで Cache-Control: no-cache を付け、ETag による軽量な検証だけは毎回行う、という設計が一般的になる。ユーザーごとに内容が変わるページ(ログイン後の画面など)では、共有キャッシュに乗らないよう private も併記する。
API サーバーのレスポンスが妙に古い値を返し続ける不具合を追うと、原因がリバースプロキシや CDN の Cache-Control 解釈にたどり着くことがある。CDN 向けには s-maxage で共有キャッシュ専用の秒数を指定でき、これはブラウザ側の max-age より優先される。フロントエンドのビルド設定でアセットに immutable を付け忘れると、デプロイのたびに全ユーザーが同じ変わらないファイルを再検証しにきて、無駄なリクエストが積み上がることもある。
公開サイトの静的ファイルに対して、キャッシュヘッダーと検証の往復を実際に観察する。
# 1回目: レスポンスヘッダーを確認(ETag を控える) curl -sI https://developer.mozilla.org/favicon.ico # 2回目: 控えた ETag を If-None-Match に渡して検証リクエストを送る curl -sI https://developer.mozilla.org/favicon.ico \ -H 'If-None-Match: "<1回目に控えたETagの値をここに>"'
ETag が一致していれば 2 回目は HTTP/2 304 が返り、Content-Length がヘッダーに現れないか極端に小さいことが確認できるはずだ。値を変えて試すと 200 OK に戻る違いも見える。
- no-cache を付ければキャッシュされない — 保存はされる。次回利用前にサーバーへ確認が入るだけで、保存自体を止めるのは
no-storeである。 - 304 は「キャッシュが効かなかった失敗」 — 304 は検証が成功し、本文の再送を省略できたことを示す成功応答である。
- ETag があれば Cache-Control は不要 —
Cache-Controlは「そもそもサーバーに聞くかどうか」を決め、ETagは「聞いた時に何と比較するか」を決める。役割が異なるため両方が必要になる場面が多い。
- 鮮度 (freshness)
- レスポンスがサーバーに問い合わせずに再利用できる期間。
max-ageで秒数を指定する。 - 検証 (validation)
- 鮮度切れ後、内容が変わっていないかをサーバーに確認する仕組み。ETag や Last-Modified を使う。
- ETag
- サーバーがレスポンスに付与する内容識別子。内容が変われば値も変わる。
- If-None-Match
- クライアントが手持ちの ETag をサーバーに送り、一致するか問い合わせるリクエストヘッダー。
- 304 Not Modified
- 検証の結果、内容が変わっていないことを示すステータスコード。本文を含まない。
- s-maxage
- CDN・プロキシなど共有キャッシュにのみ適用される鮮度秒数。ブラウザの
max-ageより優先される。
- MDN: Cache-Control — 各ディレクティブの正確な定義と、no-cache/no-store の違いが一次情報として整理されている。
- web.dev: Prevent unnecessary network requests with the HTTP Cache — 静的アセットと HTML でキャッシュ戦略をどう使い分けるかを実例付きで解説している。