Cloudflare にデプロイしたのに反映されないのはブラウザのキャッシュ
Cloudflare 上で動かしている Astro 製の CMS「EmDash」 のサイトを直して、デプロイしました。ブラウザで開くと、古いページのままでした。
ハードリロードすると新しくなります。普通のリロードでは変わりません。
エッジは新しかった
まずエッジキャッシュを疑いました。古い HTML が載っていて、それが配られているのだと思ったからです。
デプロイが終わった直後にヘッダを取りました。
$ curl -s -D- -o /dev/null https://example.com/posts/some-article
cf-cache-status: HIT
age: 201
last-modified: Sat, 30 May 2026 03:22:25 GMT(curl -sI の HEAD ではなく本文まで取る GET で見ています。HEAD はキャッシュから返るのでサーバー側には何も起きません)
age: 201 は「201 秒前に作られたコピーを返している」という意味です。デプロイの後に作り直されたものが載っています。本文まで取って中身も見ましたが、返ってきていたのは新しいほうでした。
そもそも Workers Caching は、Cloudflare の Purge Everything で Workers Cache が消えない理由 で扱ったとおり、Worker のバージョンをキャッシュキーに含みます。cross_version_cache が既定の false のとき、デプロイした新しいバージョンは空のキャッシュから始まり、前のバージョンが書いたレスポンスを配信することはありません。デプロイ直後に古い HTML がエッジに残っている状態は、そもそも起こらない構造でした。
エッジは正しく更新されていました。おかしいのは手元のブラウザだけでした。
ブラウザ向けの指示が 1 つも無かった
同じレスポンスのヘッダを全部見ると、キャッシュに関する行はこれだけでした。
cf-cache-status: HITage: 201last-modified: Sat, 30 May 2026 03:22:25 GMT
cache-control がありません。expires もありません。ブラウザに向けた指示が何も出ていませんでした。
指示が無いと、ブラウザが期間を決める
指示が無いときブラウザがどうするかは、上のヘッダに数字を入れると分かります。
last-modified は 5 月 30 日。見ていたのは 8 月の終わりでした。差は約 90 日あります。ブラウザは「今 − Last-Modified」の 10 分の 1 を目安にキャッシュしてよいと判断する実装が一般的なので、90 日 ÷ 10 で 9 日前後になります。
これが RFC 9111 に書かれているヒューリスティックな有効期限で、明示的な期限が無いとき、キャッシュは他のヘッダから期限を推測してよいことになっています。Last-Modified を使う場合の目安として 10% という数字が挙げられていますが、必須ではなく実装に委ねられています。
つまりブラウザは壊れていませんでした。「この記事は 3 か月前から変わっていないのだから、9 日くらい持っていても外れないだろう」と推測していただけです。デプロイしたことはブラウザには伝わりません。
やっかいなのは、記事が古いほど推測期間が長くなることでした。公開直後の記事は Last-Modified が新しいので推測期間も短くなります。困るのは、何か月も前に書いた記事を直したときです。今回もその場面でした。
なぜ指示が空になっていたのか
出していないつもりはありませんでした。エッジの保持期間は指定していて、使っていたヘッダが Cloudflare-CDN-Cache-Control でした。
このヘッダは Cloudflare がエッジで解釈して、クライアントに返すレスポンスからは取り除きます。ドキュメントにもそう書かれていて、curl で見ても表示されません。HTML をキャッシュ対象にするためにこのヘッダが要る事情は、Cloudflare で HTML がキャッシュされないのは既定の仕様 に書きました。
自分の中では「キャッシュの設定は済んでいる」でした。実際にはエッジ向けの指示だけを出して、ブラウザ向けの指示を 1 つも出していない状態になっていました。エッジ用のヘッダを使うと、ブラウザ向けの欄が空いたままになることに気づいていませんでした。
エッジの保持を 30 日と長く取っているのは、Cloudflare D1 の読み取りが 1 日 2,100 万行になった原因 という事情があり、Worker の起動を減らす必要があったためです。実害がどこで止まるかというと、配信されるのは古い HTML だけで、EmDash の中身は無傷です。ハードリロードすれば新しくなるので、古いと気づいた人は自力で抜けられます。逆に言えば、気づかないかぎり何も起こりません。
HTML にだけ Cache-Control を足した
サイトは Astro のサーバーレンダリング構成なので、ミドルウェアで返り値のヘッダを足しました。
function withBrowserCacheControl(response: Response): Response {
if (response.headers.has("cache-control")) return response;
if (!response.headers.get("content-type")?.includes("text/html")) return response;
const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=0, must-revalidate");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}ガードを 2 つ置いています。
既に `Cache-Control` があるレスポンスは触りません。 このサイトの RSS は public, max-age=3600 を自分で付けています。ページを配る仕組みが後から上書きすると、そこで意図した設定が消えます。
HTML 以外は触りません。 画像や API のレスポンスまで毎回確認させる理由がありません。ハッシュ付きの静的ファイルは URL が内容と対応しているので、長く持たせたほうが速くなります。
デプロイして、同じページのヘッダを取り直しました。
$ curl -s -D- -o /dev/null https://example.com/posts/some-article
cf-cache-status: HIT
age: 547
cache-control: public, max-age=0, must-revalidate
last-modified: Fri, 07 Aug 2026 13:33:19 GMTcache-control が付きました。cf-cache-status は HIT のままなので、エッジのキャッシュは巻き込んでいません。
返ってきた last-modified をそのまま If-Modified-Since に入れて投げ直すと、304 が返ります。
$ curl -s -D- -o /dev/null -H 'If-Modified-Since: Fri, 07 Aug 2026 13:33:19 GMT' https://example.com/posts/some-article
HTTP/2 304
cf-cache-status: HIT
age: 550age が 547 から 550 へ 3 秒ぶん増えているだけで、コピーは作り直されていません。確認のために来たリクエストは、エッジで 304 になって折り返しています。
2 つの層に、別々の指示を出している
直した後の状態を整理すると、こうなります。
- ブラウザ(private cache):
Cache-Controlを見る。max-age=0, must-revalidateなので毎回確認しに来る - エッジ(shared cache):
Cloudflare-CDN-Cache-Controlを優先し、無ければCache-Controlを見る。30 日持つ
同じレスポンスで、層ごとに違う保持期間になります。
max-age=0, must-revalidate は「コピーを捨てろ」ではありません。「使う前に必ず確認しろ」という指示です。確認は If-Modified-Since や If-None-Match で行われ、変わっていなければ本文の無い 304 が返ります。毎回問い合わせは発生しますが、流れるデータは小さくて済みます。
そして確認しに来ても、エッジは Worker を起動しません。自分の持っているコピーが期限内かどうかは、エッジ自身で判断できるからです。エッジで HIT になったリクエストが Worker を起動しないことは、データベースの読み取りを減らす作業のときに確かめています。ブラウザに毎回確認させても、読み取りは増えません。ここが効いていました。
層ごとに指示を分ける仕組み自体は標準にある
今回はベンダー固有のヘッダを使っていましたが、層を分けること自体は Cloudflare 固有の話ではありません。
- `Cache-Control` の `s-maxage`: RFC 9111 の標準ディレクティブ。共有キャッシュだけに効く。
max-age=0, s-maxage=2592000と書けば、原理的には今回と同じことができる - `CDN-Cache-Control`: 標準化されたエッジ向けのヘッダ。Cloudflare もこれを解釈する
- `Cloudflare-CDN-Cache-Control`: Cloudflare 固有。同社のドキュメントでは最も優先度が高い
- `Edge-Control`: Akamai のもの
- `Surrogate-Control`: Fastly のもの
ベンダー固有ヘッダの利点は、エッジが解釈した後に取り除いてくれることにあります。s-maxage はそのまま下流に流れるので、間に別のプロキシがいる構成では見えてしまいます。
今回はその利点が裏目に出た形でした。取り除かれるということは、ブラウザに届く指示は別に用意しないといけないということでもあります。「エッジ用のヘッダを書いたから設定は済んだ」と思っていたのは、そこを取り違えていました。
直しても残るもの
すでにヒューリスティックでキャッシュしたブラウザは、期限が来るまで戻ってきません。 他人のブラウザのキャッシュは消せないので、直した時点より前に来た人には効きません。しかも各自の Last-Modified からの計算なので、いつ切れるかは人によって違います。最後の 1 台がいつ更新されるかは測れません。
測れないことを測れたことにはできません。 直した後にヘッダが付いていることは確認できますが、「ブラウザが 304 で済ませている」を自分のサイトの全訪問者について確認する手段は持っていません。
10 分の 1 という数字は仕様上の必須ではありません。 RFC 9111 が挙げている目安で、実装が別の計算をしても違反にはなりません。「9 日」は自分のケースで計算した値であって、どのブラウザでも同じ日数になるとは限りません。
この構成で気をつけること
- エッジ用のキャッシュヘッダは、エッジで取り除かれてブラウザに届かない。エッジの保持期間を指定しても、ブラウザ向けの指示は別に要る
- ブラウザ向けの指示が無いレスポンスは、キャッシュされないのではなく期間をブラウザが推測する。
Last-Modifiedが古いほど長くなる - 「デプロイしたのに古い」を見たら、エッジとブラウザを分けて確かめる。
cf-cache-statusとageが正しく動いていれば、残るのはブラウザ側 max-age=0, must-revalidateはキャッシュの無効化ではなく、使う前の確認の強制になる。変わっていなければ 304 が返るので通信量は小さい- ヘッダを足すミドルウェアを書くときは、既に
Cache-Controlを持っているレスポンスを上書きしない。RSS のように個別に設定しているものが潰れる
古いページが出続けていたとき、疑ったのはエッジキャッシュとデプロイの反映でした。どちらも正常で、実際には誰にも何も指示していない欄が 1 つ空いていただけでした。指示を出さなければキャッシュされない、と思い込んでいたのが原因で、実際には指示を出さないと相手が決めます。