# 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.

Source: https://howhttpworks.com/debug/cdn-not-caching
Last reviewed: 2026-10-04

Error messages this page covers:
- `cf-cache-status: DYNAMIC`
- `cf-cache-status: BYPASS`
- `cf-cache-status: MISS`
- `X-Cache: Miss from cloudfront`
- `X-Cache: RefreshHit from cloudfront`

> **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](https://developers.cloudflare.com/cache/concepts/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.

```bash
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:

```http
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](#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.

### 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:

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 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](https://howhttpworks.com/compare/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:

```http
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](https://howhttpworks.com/tools/cache-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:

```http
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 `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

```bash
# 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](https://howhttpworks.com/headers/cache-control) lists every directive and how shared caches read them.
- [Vary](https://howhttpworks.com/headers/vary) and [Age](https://howhttpworks.com/headers/age) cover the two headers that most often confuse CDN debugging.
- [X-Cache](https://howhttpworks.com/headers/x-cache) compares the cache-status headers of other CDNs and proxies.
- [Headers and caching](https://howhttpworks.com/guides/headers-and-caching) explains freshness, validation and `304 Not Modified` end to end.
- [no-cache vs no-store](https://howhttpworks.com/compare/no-cache-vs-no-store) settles the most common directive mix-up.
