Cloudflare says "cached". The header says DYNAMIC. It was one Vary.
I'm Cody Wang, a frontend engineer working on Prismic / SvelteKit / Next.js sites and performance.
This is a bug I caused and then spent two hours finding: the cache rule was enabled, the description spelled out exactly what it does, and every request still went to origin. The cause was a response header I had forgotten I wrote.
#1. The panel says on, the header says off
I had a Cache Rule in Cloudflare called "Cache HTML at the edge (5 min)", enabled. The logic was sane: GET only, requests carrying my pa_lang cookie bypass (that's the site's language cookie), static assets excluded.
Then the usual check:
$ curl -s -D - -o /dev/null https://example.com/
HTTP/2 200
cache-control: public, max-age=300
cf-cache-status: DYNAMIC
DYNAMIC means the response is not cacheable, so every hit goes back to origin. The rule existed; it just never applied.
Time to first byte, five samples: 0.47s, 0.48s, 0.47s, 1.23s, 2.60s. Same page, same visitor, five-fold spread — that's what "everything hits origin" feels like on a two-core box.
#2. The rule wasn't wrong; the origin was blocking it
I keep the triage to three steps, no guessing:
curl -s -D - -o /dev/null <URL>— readcf-cache-statusandage- Same response — read what the origin sent as
Cache-Control - Check for
Vary
Step 3 was the one. Every response carried:
Vary: Cookie
Cloudflare's reasoning is fair: if you declare that this response varies by cookie, it cannot store one copy per URL, so it declines to cache. "Cache Everything" does not override it — Vary decides first.
And the Vary was mine. The reason sounded right at the time: the site has Chinese and English, language comes from ?lang= first and the pa_lang cookie second, so the same URL can return two languages — adding Vary: Cookie should stop a Chinese page reaching an English visitor.
The mistake was scope: I emitted it on every response, including the anonymous ones where language is decided entirely by the URL.
#3. The fix: only vary when the body actually varies
The judgment call worth remembering: Vary does not mean "I read a cookie". It means "my response body differs because of it".
An anonymous visitor gets my default language, always. Same URL, same bytes, nothing to vary — that response is safe to cache at the edge. Only a response actually rendered from the cookie needs Vary: Cookie.
So: record where the language came from.
if want in ("zh", "en"): # explicit ?lang=
self._lang_from_cookie = False
return want
m = re.search(r"pa_lang=(zh|en)", cookie_header)
if m: # cookie actually read
self._lang_from_cookie = True
return m.group(1)
self._lang_from_cookie = False # anonymous, default language
return "zh"
And emit the header conditionally:
if getattr(self, "_lang_from_cookie", False):
self.send_header("Vary", "Cookie")
The difference shows up immediately — and use GET, not curl -I. Cloudflare returns DYNAMIC for HEAD requests, which is what fooled me on the first pass:
$ curl -s -D - -o /dev/null https://example.com/
cache-control: public, max-age=300
age: 23
cf-cache-status: HIT
#4. What it changed (same site, same machine)
| Metric | Before | After | |---|---|---| | cf-cache-status | DYNAMIC (every hit to origin) | HIT | | PageSpeed performance (mobile) | 96 | 99 | | Origin server-response | 580 ms | 200 ms | | Speed Index | 4.5 s | 2.0 s | | TTFB, 5 samples from my machine | 0.47–2.60 s | 0.63–1.33 s |
That last row deserves a note: my own TTFB barely moved, because I'm in China and the handshake to a Cloudflare edge costs 0.23–1.7s. That is not the site. Judge the site by server-response, not by your own curl total.
#5. The second thing I found while looking
Static assets were uncached too. /static/app.js (53 KB) was served with:
cache-control: no-store
The static branch only set long cache headers for fonts and images; JS and CSS fell through to the default. With no content hash in the filename I did not want immutable, so:
cache-control: public, max-age=600, stale-while-revalidate=86400
#6. When you should keep Vary
This is not an argument that Vary is bad. Keep it when:
- the body genuinely varies by cookie (session, cart, A/B split)
- or you cannot change the architecture this quarter — then accept "not cached" instead of trusting a rule that cannot fire
The cleaner fix is language in the path (/en/, /zh/), so one URL means one language and Vary is unnecessary. This site picked ?lang=en back then; I'm still paying that down.
#One line
A green tick in the dashboard is not evidence. Three header fields are: cf-cache-status, age, Vary.