Debug guide · you're seeing
cf-cache-status: DYNAMICcf-cache-status: BYPASScf-cache-status: MISSX-Cache: Miss from cloudfrontX-Cache: RefreshHit from cloudfront
CDN Not Caching: cf-cache-status DYNAMIC, BYPASS, MISS
CDN not caching? Read cf-cache-status DYNAMIC, BYPASS, MISS or X-Cache: Miss from cloudfront, then fix Set-Cookie, Cache-Control, Vary and cache keys.
On this page
- What it means
- Who sent it?
- Diagnose with curl
- Fix it, in order of likelihood
- HTML and JSON are not cached by default
- The response sets a cookie
- Cache-Control says private, no-store or max-age=0
- max-age=0 for browsers killed the CDN copy too
- The request carries Authorization
- Vary on User-Agent or Cookie
- Query strings fragment the cache key
- Different data centers, or eviction
- Reading the Age header
- Reproduce and verify
- Related
TL;DR: Request the URL twice with
curl -sIand read the cache status. On Cloudflare,DYNAMICmeans it never tried (HTML and JSON are not cached by default; add a Cache Rule),BYPASSmeans your origin response forbade it (Set-Cookie,Cache-Control: private/no-store, anAuthorizationrequest,Vary: *), and repeatedMISSmeans the cache key changes on every request (query strings) or requests land in different data centers. On CloudFront, checkX-Cacheand the cache policy’s TTLs and key.
What it means
Every CDN labels each response with its cache decision. That label is the starting point; the fix depends entirely on which one you see.
Cloudflare’s cf-cache-status values, per its cache responses reference:
| Value | Meaning | Cached? |
|---|---|---|
HIT | Served from Cloudflare’s cache. | Yes |
MISS | Eligible for cache, not in this data center’s cache yet, fetched from origin. | Will be |
EXPIRED | Found in cache but expired; fetched from origin again. | Yes |
REVALIDATED | Expired copy confirmed unchanged by the origin via If-None-Match/If-Modified-Since, then served from cache. | Yes |
UPDATING | Expired copy served while Cloudflare refreshes it in the background (stale-while-revalidate). | Yes |
STALE | Expired copy served because the origin could not be reached. | Yes |
BYPASS | Eligible at request time, but the origin response was not cacheable. | No |
DYNAMIC | Not eligible for cache at request time; no cache lookup was made. | No |
NONE/UNKNOWN | Generated at the edge before the cache: a Worker response, a WAF block, a redirect rule or Always Use HTTPS. | No |
Since May 2026 Cloudflare labels every response it refuses to cache as BYPASS. Before that, some uncacheable responses, such as files over the plan’s cacheable size limit, showed MISS on every request, so older forum answers that read endless MISS as “never cached” may describe what is now BYPASS.
CloudFront reports its decision in X-Cache. The values match the result types in its access logs:
X-Cache | Meaning |
|---|---|
Hit from cloudfront | Served from the edge cache; the origin was not contacted. |
RefreshHit from cloudfront | Cached copy had expired; CloudFront revalidated it with the origin and served it from cache. |
Miss from cloudfront | Not in this edge cache; the full response came from the origin. |
Error from cloudfront | CloudFront or the origin returned an error (for example a 502 when the origin’s TLS certificate does not match). |
Fastly also sends X-Cache, simplified to HIT or MISS. A request Fastly passes to origin without caching is reported as MISS, so MISS alone cannot distinguish “not cached yet” from “never cacheable”. With shielding enabled the header can hold one entry per Fastly server (MISS, HIT); per Fastly’s documentation, any entry other than MISS means the request was answered from cache.
Who sent it?
Before blaming the CDN, make sure the response came through it. cf-ray (the last three characters are the data center code) and Server: cloudflare mean Cloudflare; Via: 1.1 ... (CloudFront), X-Amz-Cf-Pop and X-Amz-Cf-Id mean CloudFront; X-Served-By and X-Cache together usually mean Fastly. No CDN headers at all means you are hitting the origin directly, perhaps through a DNS-only (grey-clouded) record or a hosts-file entry.
A HIT in the browser that disagrees with curl is often the browser’s own cache: DevTools shows (disk cache) or (memory cache) in the Size column, and the request never reached the CDN. Test with curl or with “Disable cache” ticked.
Diagnose with curl
Send the same request twice and compare. curl -I sends HEAD; Cloudflare converts HEAD to GET for cacheable requests, so it fills the cache like a browser would. Use the GET form if you want to be sure you are testing exactly what browsers do.
URL=https://example.com/pricing
for i in 1 2; do
curl -sI "$URL" | grep -iE '^(cf-cache-status|x-cache|age|cache-control|cdn-cache-control|set-cookie|vary|cf-ray|x-amz-cf-pop)'
echo ---
sleep 2
done
# GET instead of HEAD, headers only
curl -s -o /dev/null -D - "$URL"
A healthy result looks like this. The first request fills the cache, the second is a HIT, and Age grows:
cf-cache-status: MISS
cache-control: public, max-age=60, s-maxage=3600
cf-ray: 8c1f2a3b4d5e6f70-AMS
---
cf-cache-status: HIT
age: 2
cache-control: public, max-age=60, s-maxage=3600
cf-ray: 8c1f2a3b4d5e6f71-AMS
What the second response tells you:
HITwith growingAge: caching works. If users still report slowness, look at which URLs they request (query strings, see below).DYNAMICon both: Cloudflare is not even trying. Go to HTML and JSON are not cached by default.BYPASSon both: read theSet-Cookie,Cache-ControlandVarylines you just printed; one of them is the reason.MISSon both with the samecf-raysuffix: the response is cacheable but not being kept, usually because of a very short TTL,no-cache/max-age=0revalidation, or eviction of a rarely requested object.MISSwith different suffixes: you reached two data centers; each has its own cache. Repeat a few more times.
Fix it, in order of likelihood
HTML and JSON are not cached by default
Cloudflare decides cache eligibility by file extension, not by Content-Type, and its default list covers static files (css, js, images, fonts, archives, pdf and so on) but not HTML or JSON. A page at /pricing or an API at /api/products gets DYNAMIC regardless of your Cache-Control header. To cache it, create a Cache Rule matching those paths with Eligible for cache. Development Mode, and any rule with Bypass cache, also produce DYNAMIC.
Before you do, make sure the pages are the same for every visitor. A cached HTML page with a logged-in user’s name in it will be served to everyone.
CloudFront has no extension list: every behavior has a cache policy. The managed CachingOptimized policy caches for a default of 24 hours when the origin sends no caching headers, and CachingDisabled caches nothing, so check which policy is attached to the path’s behavior.
The response sets a cookie
A Set-Cookie on the response is the most common reason for BYPASS. On Free, Pro and Business plans, where Cloudflare’s Origin Cache Control is on, the response is not cached and the cookie is passed through. Frameworks often add a session cookie to every response, including pages that do not need one, so look for it on the exact URL you are testing.
Fixes, from cleanest to bluntest:
- Stop setting cookies on cacheable pages. Set the session cookie only on login and on pages that really use it.
- Have the origin send
Cache-Control: private="Set-Cookie"(orno-cache="Set-Cookie"), which Cloudflare treats as “cache the response but drop that header”. - Remove
Set-Cookiewith a response header Transform Rule, or set an explicit Edge TTL in a Cache Rule (“Ignore cache-control header and use this TTL”), which makes Cloudflare strip the cookie and cache.
CloudFront behaves differently and more dangerously. If the behavior forwards cookies to the origin, CloudFront caches the Set-Cookie header along with the object and sends it to every viewer who gets that cached copy. If it does not forward cookies, CloudFront strips Set-Cookie from responses. Either way, a page that hands out sessions must not be cached on a shared key.
Cache-Control says private, no-store or max-age=0
private and no-store forbid shared caches from storing the response (RFC 9111 Sections 5.2.2.7 and 5.2.2.5), and every CDN honors them by default. no-cache, max-age=0 and s-maxage=0 are subtler on Cloudflare:
| Origin sends | Cloudflare with Origin Cache Control on (Free, Pro, Business default) | Cloudflare with it off (Enterprise default) |
|---|---|---|
no-store or private | Not cached, BYPASS | Not cached, BYPASS |
no-cache, max-age=0, s-maxage=0 | Cached but revalidated on every request: MISS, then REVALIDATED or EXPIRED | Not cached, BYPASS |
So REVALIDATED on every request is not a bug: the origin asked for it. See no-cache vs no-store for the difference.
CloudFront honors no-store, no-cache and private only when the behavior’s minimum TTL is 0. If the minimum TTL is above 0, AWS documents that CloudFront uses that minimum TTL even when the origin sends no-store, no-cache or private. The managed CachingOptimized policy has a minimum TTL of 1 second, so “no-store” content can still be served from cache for a second; use UseOriginCacheControlHeaders (minimum TTL 0) or a custom policy if the origin must have the final word.
max-age=0 for browsers killed the CDN copy too
A common pattern is wanting browsers to always recheck HTML while the CDN holds it. max-age=0 alone tells every cache, shared or not, that the response is immediately stale. Give shared caches their own lifetime with s-maxage, which browsers ignore:
Cache-Control: public, max-age=0, s-maxage=600
On Cloudflare you can also send CDN-Cache-Control (or Cloudflare-CDN-Cache-Control, which Cloudflare does not forward to the browser). These take precedence over Cache-Control at the edge, so a stray CDN-Cache-Control: no-store produces BYPASS even when Cache-Control looks fine. One Cloudflare-specific gotcha: s-maxage implies proxy-revalidate, which disables stale-while-revalidate there; Cloudflare’s revalidation docs list the workarounds. Build and check a header with the cache header builder.
The request carries Authorization
A shared cache must not reuse a response to a request with an Authorization header unless the response says public, s-maxage or must-revalidate (RFC 9111 Section 3.5). Cloudflare applies this rule when Origin Cache Control is on and returns BYPASS. If an API really serves the same data to every authenticated client, mark it explicitly:
Cache-Control: public, s-maxage=300
Do this only when the response does not depend on who is asking. Otherwise the first caller’s data is served to the next.
Vary on User-Agent or Cookie
Vary lists request headers that select between stored variants (RFC 9111 Section 4.1). Vary: User-Agent multiplies entries by every distinct browser string; Vary: Cookie makes nearly every visitor a separate entry. Caches that honor Vary as specified, such as nginx proxy_cache (which also refuses to cache Vary: * and Set-Cookie responses) and browsers, rarely get a hit after that.
The big CDNs treat it differently. Cloudflare ignores Vary by default except for Accept-Encoding, Vary for Images, and the Cache Rules Vary setting, but Vary: * always bypasses its cache. CloudFront builds its cache key from the cache policy, not from your Vary; it removes most Vary values from responses to viewers and, with a minimum TTL of 0, forwards every request for a Vary: * response to the origin. If you need variants, put the specific header in the cache key on purpose and normalize it (for example, a device class rather than the raw User-Agent).
Query strings fragment the cache key
Cloudflare’s default cache key includes the full query string, so /pricing?utm_source=newsletter, /pricing?fbclid=... and /pricing?_=1728000000 are three separate entries, and a campaign link can turn every visit into a MISS. Exclude parameters the origin does not use with Cache Rules or Cache Key settings. Sorting parameters only helps when the same parameters arrive in a different order.
CloudFront’s CachingOptimized policy has the opposite default: it includes no query strings, so /search?q=a and /search?q=b share one entry. If the origin’s output depends on a parameter, add it to the cache policy (or use UseOriginCacheControlHeaders-QueryStrings).
Different data centers, or eviction
Each Cloudflare data center and each CloudFront edge has its own cache, so the first request in each location is a MISS. Compare the cf-ray suffix or X-Amz-Cf-Pop across requests before concluding anything. Rarely requested objects can also be evicted before their TTL ends. Cloudflare’s Tiered Cache and CloudFront’s Origin Shield add an upper cache layer that keeps long-tail content warm.
Reading the Age header
Age is the cache’s estimate, in seconds, of how long ago the response was generated or last validated at the origin (RFC 9111 Section 5.1). Two rules make it useful:
- If
Ageis present and grows between requests, you are getting the same stored copy.Agenear yours-maxagemeans the entry is about to expire. - Absence of
Agedoes not prove the origin was contacted. Cloudflare sendsAgeonly onHIT,STALEandUPDATING, and omits it on the first localHITfilled from an upper tier. With Tiered Cache,Agereflects the object’s age across Cloudflare’s network, so a localHITcan show anAgeolder than the last local fill.
Watch for Age on DYNAMIC or BYPASS responses: Cloudflare passes an origin’s own Age header through unchanged, so it may come from another cache behind the CDN (a Varnish or nginx proxy_cache in front of your app), not from the CDN.
Reproduce and verify
# Compare edge and origin headers for the same URL
curl -sI https://example.com/pricing | grep -iE '^(cf-cache-status|x-cache|age|cache-control|set-cookie|vary)'
curl -sI -H 'Host: example.com' http://ORIGIN_IP/pricing | grep -iE '^(cache-control|set-cookie|vary)'
# Watch Age grow over 30 seconds
for i in 1 2 3; do curl -sI https://example.com/pricing | grep -iE '^(cf-cache-status|x-cache|age):'; sleep 15; done
The origin request shows what the CDN received. If the origin sends Set-Cookie or private and the edge reports BYPASS, the CDN is doing what it was told. After a fix, expect MISS once per data center, then HIT with a rising Age.
Related
- Cache-Control lists every directive and how shared caches read them.
- Vary and Age cover the two headers that most often confuse CDN debugging.
- X-Cache compares the cache-status headers of other CDNs and proxies.
- Headers and caching explains freshness, validation and
304 Not Modifiedend to end. - no-cache vs no-store settles the most common directive mix-up.
Frequently asked questions
What is the difference between cf-cache-status DYNAMIC and BYPASS?
DYNAMIC means Cloudflare decided before looking in the cache that the request was not eligible, usually because the URL is HTML or JSON, which Cloudflare does not cache by default, or because a rule bypasses cache. BYPASS means the request was eligible but the origin response could not be stored, for example because it had Set-Cookie, Cache-Control: no-store or private, or Vary: *.
Why does Cloudflare not cache my HTML pages?
Cloudflare caches by file extension, not MIME type, and its default list covers static assets such as CSS, JS, images and fonts but not HTML or JSON. Create a Cache Rule with "Eligible for cache" set for the paths you want cached, and make sure those responses carry no Set-Cookie and no private or no-store directive.
What does X-Cache: RefreshHit from cloudfront mean?
CloudFront found the object in its cache but it had expired, so it checked with the origin, which confirmed the cached copy was still current. The response came from cache without a full download. Hit from cloudfront means no origin contact; Miss from cloudfront means the full object came from the origin.
Why do I get MISS on every request even though Cache-Control allows caching?
Either each request has a different cache key or each lands in a different cache. Changing query strings (utm_ parameters, timestamps, cache busters) create a new entry per URL by default on Cloudflare; a custom cache key that includes a cookie or header does the same. Compare the cf-ray suffix or X-Amz-Cf-Pop between requests to see whether they hit the same data center.
Should I use s-maxage or max-age for a CDN?
Use s-maxage to give shared caches such as CDNs their own lifetime, and max-age for browsers. "Cache-Control: public, max-age=60, s-maxage=3600" lets the CDN hold the page for an hour while browsers recheck after a minute. Browsers ignore s-maxage. On Cloudflare, s-maxage also implies proxy-revalidate, which disables stale-while-revalidate.
How do I read the Age header?
Age is the number of seconds since the cached response was generated or last validated at the origin (RFC 9111 Section 5.1). If it grows between two requests, you are getting the same cached copy. Its absence does not prove the origin was contacted, and on Cloudflare a DYNAMIC or BYPASS response can carry an Age header that came from your origin.
Sources
- Cloudflare Docs: Cloudflare cache responses (cf-cache-status)developers.cloudflare.com
- Cloudflare Docs: Investigate uncached responsesdevelopers.cloudflare.com
- Cloudflare Docs: Default cache behaviordevelopers.cloudflare.com
- Cloudflare Docs: Origin Cache Controldevelopers.cloudflare.com
- AWS Docs: CloudFront standard logging reference (result types)docs.aws.amazon.com
- AWS Docs: Manage how long content stays in the cache (CloudFront)docs.aws.amazon.com
- AWS Docs: Use managed cache policies (CloudFront)docs.aws.amazon.com
- Fastly Docs: X-Cachefastly.com
- MDN: HTTP cachingdeveloper.mozilla.org
- RFC 9111: HTTP Cachingrfc-editor.org
Related
Age Header
Learn how the Age header indicates how long a response has been cached in seconds. Understand cache freshness calculations and CDN behavior.
X-Cache Header
Learn how the X-Cache header indicates cache hit or miss status from CDNs and proxies. Debug caching issues and verify CDN configuration with this header.
HTTP Headers and Caching: A Practical Guide
Master HTTP caching with Cache-Control, ETag, Last-Modified, and conditional request headers. Learn how to optimize performance with proper cache strategies.
Cache-Control Header: Directives, Examples and CDN Behavior
Cache-Control directives explained: max-age, no-cache vs no-store, s-maxage, stale-while-revalidate, immutable, with nginx, Cloudflare and Next.js examples.