How HTTP Works

Debug guide · you're seeing

  • cf-cache-status: DYNAMIC
  • cf-cache-status: BYPASS
  • cf-cache-status: MISS
  • X-Cache: Miss from cloudfront
  • X-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.

Reviewed 10 min readintermediate10 sourcesTry itMarkdown
On this page

TL;DR: Request the URL twice with curl -sI and read the cache status. On Cloudflare, DYNAMIC means it never tried (HTML and JSON are not cached by default; add a Cache Rule), BYPASS means your origin response forbade it (Set-Cookie, Cache-Control: private/no-store, an Authorization request, Vary: *), and repeated MISS means the cache key changes on every request (query strings) or requests land in different data centers. On CloudFront, check X-Cache and 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:

ValueMeaningCached?
HITServed from Cloudflare’s cache.Yes
MISSEligible for cache, not in this data center’s cache yet, fetched from origin.Will be
EXPIREDFound in cache but expired; fetched from origin again.Yes
REVALIDATEDExpired copy confirmed unchanged by the origin via If-None-Match/If-Modified-Since, then served from cache.Yes
UPDATINGExpired copy served while Cloudflare refreshes it in the background (stale-while-revalidate).Yes
STALEExpired copy served because the origin could not be reached.Yes
BYPASSEligible at request time, but the origin response was not cacheable.No
DYNAMICNot eligible for cache at request time; no cache lookup was made.No
NONE/UNKNOWNGenerated 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-CacheMeaning
Hit from cloudfrontServed from the edge cache; the origin was not contacted.
RefreshHit from cloudfrontCached copy had expired; CloudFront revalidated it with the origin and served it from cache.
Miss from cloudfrontNot in this edge cache; the full response came from the origin.
Error from cloudfrontCloudFront 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:

  • HIT with growing Age: caching works. If users still report slowness, look at which URLs they request (query strings, see below).
  • DYNAMIC on both: Cloudflare is not even trying. Go to HTML and JSON are not cached by default.
  • BYPASS on both: read the Set-Cookie, Cache-Control and Vary lines you just printed; one of them is the reason.
  • MISS on both with the same cf-ray suffix: the response is cacheable but not being kept, usually because of a very short TTL, no-cache/max-age=0 revalidation, or eviction of a rarely requested object.
  • MISS with 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.

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:

  1. Stop setting cookies on cacheable pages. Set the session cookie only on login and on pages that really use it.
  2. Have the origin send Cache-Control: private="Set-Cookie" (or no-cache="Set-Cookie"), which Cloudflare treats as “cache the response but drop that header”.
  3. Remove Set-Cookie with 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 sendsCloudflare with Origin Cache Control on (Free, Pro, Business default)Cloudflare with it off (Enterprise default)
no-store or privateNot cached, BYPASSNot cached, BYPASS
no-cache, max-age=0, s-maxage=0Cached but revalidated on every request: MISS, then REVALIDATED or EXPIREDNot 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 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 Age is present and grows between requests, you are getting the same stored copy. Age near your s-maxage means the entry is about to expire.
  • Absence of Age does not prove the origin was contacted. Cloudflare sends Age only on HIT, STALE and UPDATING, and omits it on the first local HIT filled from an upper tier. With Tiered Cache, Age reflects the object’s age across Cloudflare’s network, so a local HIT can show an Age older 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.

  • 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 Modified end 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

  1. Cloudflare Docs: Cloudflare cache responses (cf-cache-status)developers.cloudflare.com
  2. Cloudflare Docs: Investigate uncached responsesdevelopers.cloudflare.com
  3. Cloudflare Docs: Default cache behaviordevelopers.cloudflare.com
  4. Cloudflare Docs: Origin Cache Controldevelopers.cloudflare.com
  5. AWS Docs: CloudFront standard logging reference (result types)docs.aws.amazon.com
  6. AWS Docs: Manage how long content stays in the cache (CloudFront)docs.aws.amazon.com
  7. AWS Docs: Use managed cache policies (CloudFront)docs.aws.amazon.com
  8. Fastly Docs: X-Cachefastly.com
  9. MDN: HTTP cachingdeveloper.mozilla.org
  10. RFC 9111: HTTP Cachingrfc-editor.org

Keep going

Browse /search