HTTP キャッシュ
ブラウザ・CDN・プロキシの3層がレスポンスを一時保存する仕組み。 Cache-Control / ETag / stale-while-revalidate を使い分けると 応答時間とサーバ負荷が劇的に下がる。
キャッシュの 3 層
- ブラウザキャッシュ — 個人の端末
- CDN / Reverse Proxy(共有キャッシュ) — Cloudflare / Fastly / Vercel CDN 等
- SW Cache — Service Worker のキャッシュ
Cache-Control(最重要ヘッダー)
| ディレクティブ | 意味 |
|---|---|
max-age=N | N 秒間 fresh |
s-maxage=N | 共有キャッシュ向けの max-age |
no-cache | 毎回サーバ確認(304 で OK) |
no-store | キャッシュしない |
private | ブラウザのみ。CDN は不可 |
public | 共有キャッシュ可 |
must-revalidate | 期限切れたら必ず再検証 |
immutable | 絶対変わらない(ハッシュ付き URL に最適) |
stale-while-revalidate=N | 古くても N 秒は使い、裏で更新 |
stale-if-error=N | サーバエラー時 N 秒古くても返す |
典型パターン
1. 不変アセット(ハッシュ付き JS / CSS / 画像)
Cache-Control: public, max-age=31536000, immutable
ファイル名にハッシュ(app.a3f2b1.js)が入っていれば1年キャッシュでも安全。
変わったら別 URL になる。
2. HTML(毎回確認)
Cache-Control: no-cache
ETag で 304 が返れば実質キャッシュとして機能。「変わったら即反映」の HTML 向け。
3. API(短期 + SWR)
Cache-Control: public, max-age=60, stale-while-revalidate=300
60 秒間 fresh、その後 300 秒は古いものを返しつつ裏で更新。
4. 個人化されたページ
Cache-Control: private, max-age=0, must-revalidate
5. 絶対キャッシュしない(決済等)
Cache-Control: no-store
ETag
リソースの版識別子。ブラウザが次回 If-None-Match で送り、
サーバが一致したら304 Not Modified を本文なしで返す。
レスポンス:
ETag: "abc123"
Cache-Control: max-age=60
次回:
If-None-Match: "abc123"
→ 一致: 304 Not Modified
→ 違う: 200 OK + 新しい ETag
strong ETag vs weak ETag
- strong:
"abc"— バイト一致 - weak:
W/"abc"— 内容的に等価(Range リクエストでは使えない)
Last-Modified(古典)
日付ベースの代替。秒単位の精度なので ETag より弱い。両方付ける運用が普通。
Last-Modified: Wed, 10 May 2026 12:00:00 GMT
↓
If-Modified-Since: Wed, 10 May 2026 12:00:00 GMT
Vary
「このヘッダーで応答が変わるよ」をキャッシュに伝える。
Vary: Accept-Encoding, Accept-Language
✓ 圧縮(gzip / br / 非圧縮)でレスポンスが違うので必須
✓ 認証ありの API は Vary: Authorization
✗ Vary: User-Agent はキャッシュヒット率を下げて避ける
Surrogate-Control(CDN 専用)
Fastly や一部 CDN が見るCDN だけに効くキャッシュヘッダー。 ブラウザは無視するので、CDN を長く・ブラウザを短くできる。
Cache-Control: max-age=60 (ブラウザ)
Surrogate-Control: max-age=86400 (CDN)
CDN のキャッシュ
Cloudflare / Vercel / Fastly などが各地のエッジでレスポンスをキャッシュ。
パージ
- 個別 URL: API でパージ命令
- 全削除: 緊急時。サーバが一気に負荷
- タグベース:
Cache-Tag: products等で関連 URL を一括
キャッシュキー
既定では URL + Vary。CDN 側でクエリ・Cookie・ヘッダーをキーから外す/入れることで ヒット率を上げられる。
SW Cache(Cache Storage API)
詳細は PWA キャッシュ。
SW で caches.match / caches.put を使う。
fetch のキャッシュモード
default— 通常のブラウザキャッシュno-store— キャッシュしないreload— 強制取得 + キャッシュ更新no-cache— 必ず再検証force-cache— キャッシュ優先only-if-cached— キャッシュのみ
SWR(Stale-While-Revalidate)
Cache-Control の stale-while-revalidate ディレクティブ。古くても返して、裏で更新。
UX と新鮮さの折衷。
Cache-Control: max-age=10, stale-while-revalidate=600
- 0〜10秒: fresh、即返す
- 10〜610秒: stale、即返しながら裏で再取得
- 610秒以降: 通常のリフレッシュ
SWR を活用する CDN
- Vercel の ISR(Incremental Static Regeneration)が SWR ベース
- Cloudflare も対応
- Next.js / Astro 等は revalidate オプションで内部的に SWR
ブラウザの bfcache
Back-Forward Cache。ページを丸ごと保存して戻る/進むで瞬時復元。 通常のキャッシュとは別レイヤ。
- JS の状態も保持される(イベントリスナーが生きたまま)
unloadイベント等を使うと無効化されるpageshow/pagehideイベントで対応Cache-Control: no-storeは bfcache を無効化
bfcache を有効に保つコツ
unloadリスナーを使わない(pagehideに)- WebSocket は
pagehideで閉じる Cache-Control: no-storeを必要なページだけに
Vary と Cookie の悩ましい関係
Vary: Cookie にすると Cookie ごとに別キャッシュ → ヒット率が下がる。
Cookie で出力が変わるのはキャッシュ向きではない。
共通部分をSSR + パブリックキャッシュ、個人化部分をクライアント fetchで取るのが定石。
Service Worker と HTTP キャッシュの優先順位
- SW の
fetchイベントが先に動く - SW が
fetch(req)すると HTTP キャッシュが効く - SW で
cache: "no-store"等を指定可能
キャッシュの落とし穴
- HTML にハッシュ付き URL を入れずに長期キャッシュ — 永遠に古いコードが動く
- API に長期キャッシュをかけて止まらない — パージできず詰む
Vary: User-Agent— キャッシュ細分化でヒットしない- 個人情報を public でキャッシュ — CDN に漏れる
- 304 で
Set-Cookieを返さない — セッションが切れる
✓ 静的ハッシュ付きアセット: max-age=31536000, immutable
✓ HTML: no-cache + ETag
✓ API(公開): max-age=60, stale-while-revalidate=300
✓ 個人化 API: private, no-cache
✓ Vary: Accept-Encoding を必ず付ける