Prismic Page Audit Free online audit tool
Home › Notes › Cloudflare says "cached". The header says DYNAMIC. It was one Vary.

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:

  1. curl -s -D - -o /dev/null <URL> — read cf-cache-status and age
  2. Same response — read what the origin sent as Cache-Control
  3. 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.