cf-cache-status explained: every value, what it costs you, and how to fix it
cf-cache-status is the single most informative header Cloudflare attaches
to a response. One word tells you whether the request was answered in milliseconds from a
data center near the visitor — or traveled all the way to your origin server and back.
This guide explains every value, the difference between the confusing ones
(DYNAMIC vs. BYPASS, MISS vs. EXPIRED),
and the concrete fix for each.
How to check it
Three ways, from quick to thorough:
- DevTools: Network tab → click the request → Response Headers →
cf-cache-status. - curl:
Thecurl -sI https://example.com/ | grep -iE 'cf-cache-status|cf-ray|age'cf-raysuffix (e.g.…-FRA) tells you which data center answered — important, because each one has its own cache. (Decode any code in our list of all Cloudflare data centers.) - Multi-location check: a HIT from your desk says nothing about the 280+ other Cloudflare colos. Our free cache checker requests your URL from six locations on four continents simultaneously and shows the status per location.
Every cf-cache-status value
| Status | What happened | Cost to the visitor |
|---|---|---|
HIT | Served from the edge cache. Origin never contacted. | ✅ None — this is the goal |
MISS | Cacheable, but not in this colo's cache. Fetched from origin, stored for next time. | ⚠️ Full origin round trip |
EXPIRED | Was in cache, but the edge TTL ran out. Re-fetched from origin. | ⚠️ Full origin round trip |
STALE | Served the expired copy while fetching a fresh one in the background. | ✅ Fast, content slightly old |
UPDATING | Like STALE, but the refresh is already in flight (popular objects). | ✅ Fast |
REVALIDATED | Expired entry checked against origin — still fresh (304), served from cache. | ✅ Cheap origin ping only |
DYNAMIC | Not eligible for caching under default rules. Every request goes to origin. | ❌ Never cached |
BYPASS | Eligible, but explicitly opted out (no-store, private, cookie, bypass rule). | ❌ Never cached |
NONE / UNKNOWN | No cache lookup happened — Workers subrequests, redirects, errors. | — depends |
| (header absent) | The response didn't pass through Cloudflare's cache at all. | ❌ Check your DNS/proxy status |
DYNAMIC: the one that surprises everyone
Paste any normal web page into a header checker and you'll almost certainly see
DYNAMIC. That's not a bug — it's Cloudflare's default: only static
file extensions are cached (images, CSS, JS, fonts, PDFs, …).
HTML is not on the list. Your carefully tuned origin cache headers don't
change this; Cloudflare ignores them for ineligible content types.
Two ways to make HTML cacheable:
- Cache Rule (dashboard → Caching → Cache Rules): match your hostname
or path, set "Eligible for cache", and choose an edge TTL. Exclude logged-in traffic by
cookie:
(http.host eq "example.com" and not http.cookie contains "session") Cloudflare-CDN-Cache-Controlheader from your origin — it instructs Cloudflare's cache specifically, without affecting browsers:Cloudflare-CDN-Cache-Control: max-age=14400 Cache-Control: max-age=0, must-revalidate
The full walkthrough, including Workers Cache and APO, is in our Cloudflare cache warming guide.
BYPASS: cacheable, but opted out
BYPASS looks similar to DYNAMIC but has the opposite cause:
the content was eligible, and something explicitly told Cloudflare not to cache
this response. The usual suspects:
Cache-Control: no-store,no-cacheorprivatefrom your origin,- a
Set-Cookieheader on the response (very common: a framework starting sessions on every request), - a Cache Rule or Page Rule set to "Bypass cache".
Fix the origin header or the stray cookie and BYPASS becomes
MISS on the next request — then HIT after that.
MISS and EXPIRED: the geography problem
A MISS isn't a configuration error — it's the price of a cold cache.
What most people underestimate is how often caches are cold:
- Per location: Cloudflare runs 280+ data centers, and each caches independently. A HIT in Frankfurt is meaningless for the visitor routed to Singapore. (Verify it yourself with the multi-location checker.)
- Per TTL: every entry expires after the edge TTL and the next
request is
EXPIRED— economically identical to a MISS. - Per purge: every deploy with "purge everything" resets all locations to cold at once.
- Per eviction: unpopular objects get evicted long before their TTL to make room — the long tail of your site is cold almost everywhere, almost always.
Organic traffic only fixes this for your most popular pages in your busiest regions. For everything else, the first visitor in each region pays the origin round trip — unless you fill the caches proactively. That's what warmup.rocks does: it requests every URL in your sitemap from 90+ edge locations on a schedule, so the cache is already warm when a real visitor arrives, and shows you the resulting hit ratio per location after every run.
STALE, UPDATING, REVALIDATED: the good expired states
These three mean your configuration is working well: instead of making a
visitor wait for origin, Cloudflare served the slightly-old copy (STALE,
UPDATING) or confirmed freshness with a cheap 304 (REVALIDATED).
You get them by sending validators (ETag / Last-Modified) and,
on Enterprise, stale-while-revalidate directives. No action needed — count
them as hits in your monitoring.
Debug checklist
- No
cf-cache-statusat all? The hostname isn't proxied (grey cloud in DNS) or the request bypasses Cloudflare entirely. DYNAMICon HTML? Expected default — add a Cache Rule orCloudflare-CDN-Cache-Control.BYPASS? Find theno-store/privatedirective or theSet-Cookieon the response.MISSthat never becomesHIT? Checkcf-ray— you're probably hitting different colos per request. Test twice against the same colo before concluding anything.HIThere, slow for users elsewhere? Run the multi-location check — other regions are likely cold, and cache warming is the fix.
Make every location report HIT
warmup.rocks warms your pages in 90+ Cloudflare edge locations on a schedule and shows the cf-cache-status result per URL, per colo, after every run.
Start your 7-day free trial