If you run curl -I against a site behind Cloudflare and see
cf-cache-status: DYNAMIC, Cloudflare isn't caching that response at all. It didn't try
and fail. It never considered it. Every request for that page goes through to your server, so you get
Cloudflare's network and its DNS, but none of the caching.
That's how HTML works on Cloudflare by default, and it confused me for longer than I'd like to
admit. My images, CSS and JavaScript were all coming back HIT, so I assumed the pages
were cached too. They weren't. It only changed on 17 October 2026, when I added a cache rule to this
site. You can still see the old behaviour today on the one path that rule leaves out, which makes it a
handy live example.
Below are the headers from this site, what each cf-cache-status value actually
means, why DYNAMIC and BYPASS are not the same problem, and the rule I use
to cache HTML without caching anything I shouldn't.
Short answer:
DYNAMICmeans no cache setting applies to the response. Cloudflare only caches certain file extensions by default (images, CSS, JS, fonts, PDFs and so on), and HTML and JSON are not on that list. SendingCache-Control: s-maxage=...from your server doesn't change that on its own. You need a Cache Rule that marks the URLs as eligible for cache.
Check what Cloudflare is doing with a page
This is all you need:
curl -sI https://example.com/ | grep -iE "cf-cache-status|cache-control|age"
And here is what four requests to this site returned on 12 November 2026. The first one is the interesting bit.
$ curl -sI https://knowledgemarkg.com/admin
cache-control: s-maxage=600, stale-while-revalidate=31535400
cf-cache-status: DYNAMIC
$ curl -sI https://knowledgemarkg.com/blog
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
cf-cache-status: BYPASS
$ curl -sI https://knowledgemarkg.com/
cache-control: s-maxage=300, stale-while-revalidate=31535700
cf-cache-status: REVALIDATED
$ curl -sI https://knowledgemarkg.com/
cache-control: s-maxage=300, stale-while-revalidate=31535700
age: 0
cf-cache-status: HIT
/admin sends s-maxage=600, which says shared caches may keep it for ten
minutes. Cloudflare still answers DYNAMIC, because I deliberately left /admin
out of the cache rule. (It's a static shell. The data loads after you log in, but I'd still rather
nothing near the admin sits in a CDN.) So the header alone did nothing. The rule is what turns caching
on.
What each cf-cache-status value means
| Value | What happened | Seen on this site |
|---|---|---|
HIT | Served from Cloudflare's cache. Your server wasn't contacted. | Home page, posts, JS chunks, sitemap |
MISS | Eligible for cache but not in it yet, so it was fetched from your server and (usually) stored. | Any page with a new query string |
EXPIRED | It was in the cache, but its TTL had run out, so Cloudflare fetched a fresh copy. | Posts after their 60 s, /tools, robots.txt |
REVALIDATED | The cached copy was stale, Cloudflare checked with your server, and it was still good to use. | Home page |
UPDATING | A stale copy was served while Cloudflare fetched a new one in the background. | Not caught yet |
STALE | A stale copy was served because Cloudflare couldn't refresh it, for example because your server was down. | Not seen |
BYPASS | Cloudflare would have cached it, but the response said not to. | /blog, 404 pages |
DYNAMIC | Nothing told Cloudflare to cache it, so it didn't consider it. | /admin, the API |
NONE / UNKNOWN | The response never got as far as the cache, for example it came from a Worker or a redirect rule. | Not seen |
Don't panic about MISS or EXPIRED on a single request. They're normal. What
matters is that the second request comes back HIT.
Why HTML comes back DYNAMIC
Cloudflare decides what to cache by file extension unless you tell it otherwise. .jpg,
.css, .js, .woff2, .pdf and a few dozen others are on
the default list. .html isn't, and neither is .json. A URL like
/blog/my-post has no extension at all. So almost every page on a modern site, and every API
response, is DYNAMIC out of the box.
That's a sensible default. Cloudflare can't tell whether your HTML is the same for everyone or contains somebody's basket, and caching the second kind would be a privacy incident. So it plays safe and leaves the decision to you.
My API is a good example of where that default is right. It lives on a separate host
(api.knowledgemarkg.com), the rule doesn't cover it, and every response is
DYNAMIC. That's what I want there, because the API has its own output cache and some of
its responses depend on who is asking.
DYNAMIC vs BYPASS
These two look alike in a report (both mean "came from the origin") but they have opposite causes, so the fix is different.
DYNAMIC is Cloudflare's side. No rule made the URL eligible, so it
never looked at your headers. Fix it with a Cache Rule.
BYPASS is your server's side. The URL was eligible, but the
response said no. Typical reasons are Cache-Control: private, no-store,
no-cache or max-age=0, or a Set-Cookie header on the response.
Adding more Cloudflare rules won't help, and usually you shouldn't try. Find out why the server says
no first.
On this site /blog is BYPASS on purpose. That page reads the query string
for search and paging, so Next.js renders it on every request and sends
private, no-store. The 404 page is BYPASS too, and that one I set up myself
after Cloudflare cached a 404 for a post that went live a few minutes later. Readers kept getting
"not found" for about ten minutes. I wrote that one up separately:
does Cloudflare cache 404s.
How to make Cloudflare cache HTML (Cache Rules)
The old way was a Page Rule with Cache Level: Cache Everything. Cloudflare now points you to Cache Rules instead, and they're on the free plan. This is the rule running on this site:
- In the Cloudflare dashboard, open your domain and go to Caching → Cache Rules → Create rule.
- Name it something you'll recognise later. Mine is "Cache HTML pages".
- Under When incoming requests match, choose Custom filter expression and use something like:
(http.host eq "example.com"
and not starts_with(http.request.uri.path, "/admin")
and not starts_with(http.request.uri.path, "/api")
and not starts_with(http.request.uri.path, "/account"))
- Under Cache eligibility, pick Eligible for cache.
- Under Edge TTL, choose Use cache-control header if present, bypass cache if
not. Your server stays in charge of how long each page lives. Next.js already sends
s-maxagefor static and ISR pages andprivate, no-storefor dynamic ones, so on this site the rule needed no TTLs of its own. - While you're there, add a status code TTL with 404 and 410 set to No store, unless you like the problem from the previous section.
- Save, then purge the cache once (Caching → Configuration → Purge Everything).
Leave out anything that's personal to a visitor: admin areas, account pages, carts and checkouts, API
routes. On WordPress that means /wp-admin and anything that changes for logged-in users.
If your server already sends private or no-store on those responses you're
covered twice, and that's no bad thing.
Check that it worked
for i in 1 2; do
curl -s -o /dev/null -D - https://example.com/ | grep -iE "cf-cache-status|^age"
done
# 1st: MISS (or EXPIRED / REVALIDATED) 2nd: HIT
The Age header tells you how many seconds the copy has been in Cloudflare's cache. If
the second request is still DYNAMIC, the rule isn't matching. Check the hostname (with or
without www) and the rule order. If it says BYPASS, the rule is matching and
your server is refusing, so look at the response headers again.
Was it worth it? The numbers from here
I measured time to first byte with curl, five runs each, comparing the cached home page with the same page plus a random query string (a new cache key, so Cloudflare has to ask my server):
# cached copy vs a URL Cloudflare has never seen (random query string = new cache key)
curl -s -o /dev/null -w "%{time_starttransfer}\n" https://example.com/
curl -s -o /dev/null -w "%{time_starttransfer}\n" "https://example.com/?nocache=$RANDOM"
Cached copies came back in 37 to 50 ms. The uncached ones took 255 to 311 ms. That's one machine, in one place, on one evening, so don't treat it as a benchmark. It's also a fair comparison only up to a point, because my server answered those misses from Next.js's own cache. A page that has to hit a database would be slower again. Still, cutting a quarter of a second off every first byte matters more than most of the front-end tweaks I've spent time on.
One thing that will bite you after a deploy
Once Cloudflare caches your HTML, it also keeps the old HTML after you deploy. That HTML points at the JavaScript and CSS files from your previous build, which have different hashed names and may no longer exist on the server. I skipped the purge once and got a site full of unstyled pages until each one expired. Since then, every frontend deploy here ends with Purge Everything. The longer version, with the actual error, is in ChunkLoadError after a deploy.
If your pages still aren't updating after a purge, the stale copy may be one layer further in, in
Next.js's own cache. That's a separate
problem, and the x-nextjs-cache header next to cf-cache-status tells you
which layer you're looking at.
Setting this up on your own site and getting results that don't make sense? Untangling cache layers like this is part of what I do for clients. Get in touch.





Join the conversation
No comments yet — be the first to share what you think.