Interpreting X-Cache and CF-Cache-Status Headers
When a response doesn’t behave the way its Cache-Control directives suggest it should, the fastest diagnostic path isn’t re-reading the origin configuration — it’s reading the cache-status header the CDN or proxy already attached to the response. Every major caching layer exposes its internal hit/miss decision through a dedicated header, but the header name and its vocabulary of values differ by vendor, and misreading one status for another routinely sends engineers debugging the wrong layer.
Prerequisite Concepts
This page assumes familiarity with:
- Cache hit, miss, and bypass mechanics — the underlying decisions a cache makes before it can report HIT or MISS.
- How CDN cache keys are generated — a mismatched key is one of the most common reasons a status header reports MISS unexpectedly.
- How to read and interpret the Age header —
Ageand the cache-status header should always be read together, since a HIT withAge: 0is a different signal than a HIT withAge: 3600.
Step-by-Step Resolution
Step 1 — Query Cloudflare’s CF-Cache-Status
curl -sI https://example.com/asset.js \
| grep -iE '(cf-cache-status|cache-control|age)'
Cache-Control: public, max-age=3600
Age: 212
CF-Cache-Status: HIT
Cloudflare’s CF-Cache-Status header uses a fixed vocabulary. Read it against this table:
| Value | Meaning |
|---|---|
HIT |
Served directly from Cloudflare’s edge cache; no origin request was made. |
MISS |
Cacheable, but not present at this edge node; the request was proxied to origin and the response stored. |
EXPIRED |
A stored object existed but its freshness lifetime had elapsed; the edge revalidated or re-fetched from origin. |
STALE |
A stale object was served — typically because stale-while-revalidate or stale-if-error permitted it while a background refresh occurred. |
BYPASS |
Cloudflare deliberately skipped the cache for this request, usually due to a Cache Rule, a no-store directive, or an authenticated Set-Cookie response. |
DYNAMIC |
The response was classified as not cacheable by default (content type, method, or missing cache eligibility) and was never evaluated for storage. |
REVALIDATED |
The edge held a stale copy, sent a conditional request to origin, and the origin returned 304 Not Modified — the stored body was reused with a refreshed freshness lifetime. |
UPDATING |
A background refresh triggered by stale-while-revalidate is currently in progress; the request that produced this status was served the previous stale copy. |
Step 2 — Query Fastly’s X-Served-By and X-Cache
curl -sI https://example.com/asset.js \
| grep -iE '(x-served-by|x-cache|x-cache-hits|cache-control|age)'
Cache-Control: public, max-age=3600
Age: 88
X-Served-By: cache-lhr7351-LHR
X-Cache: HIT
X-Cache-Hits: 14
X-Served-By names the point of presence (or chain of POPs, comma-separated, if the request traversed a shield) that handled the request — it does not by itself say hit or miss. X-Cache carries the actual HIT or MISS verdict, and X-Cache-Hits is a running counter of how many times that specific cached object has been served from that node since it was stored. An X-Cache: HIT paired with X-Cache-Hits: 0 is a contradiction worth investigating — it usually means the counter header was passed through from a downstream layer rather than generated at this hop.
Step 3 — Query CloudFront, Varnish, and Nginx status headers
CloudFront and generic Varnish deployments conventionally use X-Cache, though the value format differs from Fastly’s binary HIT/MISS:
curl -sI https://d111111abcdef8.cloudfront.net/asset.js \
| grep -iE '(x-cache|cache-control|age)'
Cache-Control: public, max-age=86400
Age: 5031
X-Cache: Hit from cloudfront
CloudFront X-Cache value |
Meaning |
|---|---|
Hit from cloudfront |
Served from the edge location without contacting origin. |
RefreshHit from cloudfront |
Stored object was stale; the edge revalidated with origin and reused the cached body. |
Miss from cloudfront |
Not present at the edge; fetched from origin (or a regional edge cache) and stored. |
Error from cloudfront |
The edge could not retrieve a valid response, from cache or origin. |
Nginx’s proxy_cache module reports status through a header you name yourself via add_header X-Cache-Status $upstream_cache_status;, with a distinct vocabulary that includes states the others don’t expose directly:
curl -sI https://example.com/asset.js \
| grep -iE '(x-cache-status|cache-control|age)'
Cache-Control: public, max-age=600
X-Cache-Status: STALE
Nginx X-Cache-Status value |
Meaning |
|---|---|
HIT |
Served from the local disk/memory cache zone. |
MISS |
Not present in the cache zone; proxied to the upstream and stored if eligible. |
BYPASS |
proxy_cache_bypass conditions matched; the cache was skipped for this request. |
EXPIRED |
The cached entry’s proxy_cache_valid window elapsed; a fresh copy was fetched. |
STALE |
A stale entry was served because the upstream was unreachable and proxy_cache_use_stale permitted it. |
UPDATING |
A background refresh is in progress and the previous cached copy is being served in the meantime. |
Step 4 — Cross-reference the value against the diagnostic table
Once you have the status value, map it to a root cause instead of re-guessing:
| Observed status | Likely cause | Where to look |
|---|---|---|
MISS on every request to the same URL |
Vary fragmenting the cache key across a header that changes per request |
Mapping Vary headers to edge routing |
BYPASS / DYNAMIC on a static asset |
Origin sent no-store, private, or a Set-Cookie on the response |
Origin response headers, not the CDN configuration |
EXPIRED far sooner than expected |
max-age/s-maxage lower than assumed, or a platform Cache Rule overriding origin TTL |
How to calculate cache freshness lifetime |
HIT with an unexpectedly low Age |
Object was purged or evicted and just re-populated — not a persistent miss, but worth confirming with a repeat request | How to read and interpret the Age header |
Node keeps reporting MISS while sibling nodes report HIT |
Request routed to a cold edge node or origin shield tier that hasn’t populated yet | Origin shielding and request collapsing |
Expected Output / Verification
A healthy, correctly cached static asset should show a consistent pattern across repeated requests within its freshness window:
for i in 1 2 3; do
curl -sI https://example.com/asset.js | grep -iE '(cf-cache-status|age)'
sleep 2
done
CF-Cache-Status: HIT
Age: 40
CF-Cache-Status: HIT
Age: 42
CF-Cache-Status: HIT
Age: 44
A stable HIT (or its vendor equivalent) with a monotonically increasing Age confirms the edge is serving from storage rather than round-tripping to origin. If the status flips between HIT and MISS on consecutive identical requests from the same client, the request is likely load-balanced across multiple edge nodes that have not yet converged on a cached copy, or a shield/origin tier is not in front of them — check whether an origin shield is configured.
Edge Cases
- Browsers don’t expose CDN status headers by default.
CF-Cache-StatusandX-Cacheare CDN-to-client headers, but browser DevTools’ “Size” column note ((disk cache),(memory cache)) reflects the browser’s own private cache, which is a separate layer from the CDN’s. Read both independently. - Multiple
X-Cachevalues on one response. Chained CDNs or a CDN-in-front-of-CDN topology can append more than oneX-Cacheline, one per hop. Read them top-to-bottom to trace the path; the last one closest to the client is what actually determined the response latency. - Status headers can be stripped at the edge. Some CDN configurations strip diagnostic headers on production traffic for security reasons and only expose them on a staging hostname or with a bypass token. Confirm the header is intentionally exposed before concluding a cache layer doesn’t report status at all.
REVALIDATEDandRefreshHitare not the same as a freshHIT. Both indicate a conditional request to origin occurred, meaning origin load was incurred even though the client received a cache-served body — factor this into origin capacity planning, not just edge hit-rate dashboards.
Frequently Asked Questions
What does CF-Cache-Status: DYNAMIC mean?
DYNAMIC means Cloudflare classified the response as not cacheable by default — usually due to the request method, content type, or a Cache Rule — and passed it straight to the origin without evaluating it for storage at all.
Why does X-Cache show MISS even though the response should be cacheable?
A MISS on a response you expect to be cacheable is usually caused by an explicit no-store or private directive on the origin response, a Set-Cookie header triggering an automatic bypass, a Vary header fragmenting the cache key, or the object exceeding the platform’s cacheable size limit.
What is the difference between X-Cache and X-Cache-Status?
X-Cache is the conventional header name used by CloudFront, Varnish, and Fastly. X-Cache-Status is the header name Nginx’s proxy_cache module uses by convention, and it additionally exposes STALE and UPDATING states tied to stale-serving configurations.
Does a HIT in X-Served-By guarantee a cache hit?
No. X-Served-By only names the Fastly POP(s) that handled the request. You must read X-Cache alongside it, and use X-Cache-Hits to confirm the object has actually been served from cache more than zero times.
Related
- How CDN cache keys are generated — the key construction that determines whether a request even has a chance of producing a
HIT. - How to debug CDN cache key mismatches — a deeper walkthrough for when the status header itself isn’t enough to explain a persistent miss.
- Using
Varymismatches as a cache diagnostic signal — covers the specific pattern ofAgeresetting to zero that a status header alone won’t explain. - Tag-based cache invalidation patterns — relevant when a status header reports
MISSimmediately after a deliberate purge rather than a misconfiguration.