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: DYNAMIC means 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. Sending Cache-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:

  1. In the Cloudflare dashboard, open your domain and go to Caching → Cache Rules → Create rule.
  2. Name it something you'll recognise later. Mine is "Cache HTML pages".
  3. 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")) 
  1. Under Cache eligibility, pick Eligible for cache.
  2. 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-maxage for static and ISR pages and private, no-store for dynamic ones, so on this site the rule needed no TTLs of its own.
  3. 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.
  4. 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.

FAQ

What does cf-cache-status DYNAMIC mean?

Cloudflare did not consider the response for caching, so it was fetched from your server. By default Cloudflare only caches certain file extensions (images, CSS, JavaScript, fonts and so on). HTML pages and JSON responses are not on that list, so they show DYNAMIC unless a Cache Rule makes them eligible.

What is the difference between cf-cache-status DYNAMIC and BYPASS?

DYNAMIC means no Cloudflare setting made the URL eligible for cache. BYPASS means it was eligible, but the response told Cloudflare not to store it, usually with Cache-Control private, no-store, no-cache or max-age=0, or a Set-Cookie header. Fix DYNAMIC with a Cache Rule. For BYPASS, look at your server's response headers.

Does Cloudflare cache HTML pages by default?

No. HTML is not among the file types Cloudflare caches by default, so pages come back DYNAMIC. To cache them, create a Cache Rule that sets the matching URLs to Eligible for cache, and exclude admin, account, cart and API paths.

Is DYNAMIC bad for performance?

It means every request goes to your server, so time to first byte depends on your server's distance and speed. On this site a cached page took 37-50 ms to the first byte from my machine, against 255-311 ms for the same page fetched from the origin. For pages that differ per visitor, DYNAMIC is the correct, safe result.

Does Cloudflare respect s-maxage?

Yes, once the URL is eligible for cache and the Edge TTL is set to use the origin's Cache-Control header. Cloudflare uses s-maxage for its own cache in preference to max-age. On its own, sending s-maxage does not make an HTML page eligible. It still shows DYNAMIC without a rule.

Is a Cloudflare Cache Rule the same as Cache Everything?

Setting a Cache Rule to Eligible for cache does what the older Page Rule setting Cache Level: Cache Everything did: it lets Cloudflare cache responses regardless of file extension. Cache Rules are the newer feature and are available on the free plan.

Do I need to purge Cloudflare after every deploy?

If Cloudflare caches your HTML, yes, or at least purge the pages that changed. Cached HTML points at the CSS and JavaScript file names from the previous build. If those files are gone, visitors get unstyled or broken pages until each cached copy expires.

Originally published on knowledgemarkg.com.