Cache-Status Header: Debug CDN Hits and Misses (RFC 9211)
Learn how RFC 9211's Cache-Status header exposes hits, misses, forwards, and coalesced requests across your CDN chain. Includes vendor mapping and TTFB attribution patterns.
The Cache-Status HTTP response header, standardized in RFC 9211, tells you exactly how each cache in the delivery chain handled a request. Was it a hit, a miss, forwarded to origin, stored for later, or collapsed with another in-flight request? It replaces the tangle of vendor-specific headers (CF-Cache-Status, X-Vercel-Cache, X-Cache) with one uniform, parseable grammar. If you own TTFB, cache invalidation, or edge behavior, this is the header you should be logging.
Cache-Status is the IETF-standard header (RFC 9211, published June 2022) that describes how one or more HTTP caches processed a request.
It uses a structured field: a list of cache identifiers, each with parameters like hit, fwd, fwd-status, ttl, stored, collapsed, key, and detail.
Multi-tier chains (browser, CDN, shield, origin cache) append entries left-to-right, so the rightmost entry is the cache closest to the client.
Vendor headers still dominate production: Cloudflare uses CF-Cache-Status, Vercel uses X-Vercel-Cache, Fastly and Varnish use X-Cache. Netlify emits both Cache-Status and its legacy header.
Correlating Cache-Status with Server-Timing and Age gives you full TTFB attribution. You can see which layer served the byte and how stale it was.
Enable emission at your CDN and log the raw header in RUM. Don't synthesize it from vendor headers, because fwd=stale and collapsed have no vendor equivalents.
What is the Cache-Status header?
Cache-Status is an HTTP response header defined by IETF RFC 9211 that lets any cache (browser, forward proxy, CDN edge, shield, or origin accelerator) document how it processed the request. Before RFC 9211, every vendor had its own dialect: X-Cache from Varnish and Fastly, CF-Cache-Status from Cloudflare, X-Vercel-Cache from Vercel, Fly-Cache-Status, X-Amz-Cf-Pop, and dozens more. Debugging a multi-CDN topology meant memorizing five vocabularies. The RFC unifies them with one Structured Fields list.
So, a minimal example looks like this:
Cache-Status: "ExampleCDN"; hit; ttl=376
That says: a cache identifying itself as ExampleCDN served this response from cache, and the object had 376 seconds of remaining freshness when it was sent. The identifier is a quoted string of the cache operator's choosing, so pick anything human-readable. Multiple caches chain together with commas:
Read left-to-right: the origin's NGINX served a hit to the Fastly shield POP, which served a hit to the edge POP, which served a hit to the client. Every hop is accounted for. In my experience running edge tiers, this alone is worth adopting the header. The moment a customer says "why is TTFB 800 ms in Sydney," you can look at one header and know whether the byte came from the edge, the shield, or all the way back to origin.
Cache-Status parameters, explained
Each cache entry in the list carries a set of key-value parameters. RFC 9211 defines eight standard ones and reserves the detail namespace for vendor extensions. Learn these eight and you can read any compliant cache's status without a lookup table.
hit (boolean)
The response was served from stored cache without contacting a downstream cache or origin. Presence alone is truthy: hit and hit=?1 are equivalent. If hit is present, fwd must not be.
fwd (token)
The cache forwarded the request. The value tells you why. Standard values:
bypass: the cache was configured to skip this request (for example, admin routes, cookie-bearing POSTs).
method: the request method is not cacheable (POST, PUT, DELETE).
uri-miss: nothing matching this URI was in the cache.
vary-miss: a URI match existed but Vary-selected variants did not.
miss: a catch-all "no matching stored response," used when the cache does not distinguish uri-miss from vary-miss.
request: a fresh response existed but request semantics (like Cache-Control: no-cache) forbade its use.
stale: the stored response was too stale to serve as-is.
partial: the stored response was a partial (Range) that did not satisfy the request.
fwd-status (integer)
The HTTP status the downstream returned. Only meaningful with fwd. A Cache-Status: "edge"; fwd=stale; fwd-status=304 tells you the object was stale, the cache revalidated with a conditional request, and the origin said "not modified." That's a great outcome. It costs one RTT of TTFB but zero body bytes.
ttl (integer, may be negative)
Remaining freshness lifetime in seconds at the moment the response was emitted. Negative values mean the response was already stale. That's legal when your cache serves stale under stale-while-revalidate or stale-if-error.
stored (boolean)
The cache saved the response for later. A first-time miss that populated the cache will show fwd=miss; stored. Requests that fwd=bypass generally do not store.
collapsed (boolean)
The cache joined an already-in-flight upstream request instead of issuing a new one. This is request collapsing, sometimes called single-flight coalescing. Honestly, this is the header field that tells you "yes, my thundering-herd protection is working." No vendor header exposes this today, which is one of the strongest reasons to adopt RFC 9211.
key (string)
The cache key used to look up the response. Useful when your key includes query parameters, cookies, Vary headers, or geo dimensions. Debugging a "same URL, different response" bug becomes trivial when the key is echoed back.
detail (string or token)
Cache-specific free-form information. Semantics are private to the emitting cache. A Netlify entry might carry detail=engine-warm; a Squid entry might carry detail=HIT_MEM. Don't compare detail across caches.
Cache-Status vs CF-Cache-Status, X-Vercel-Cache, X-Cache
RFC 9211 is not yet universal in production. If you run behind Cloudflare, Vercel, Fastly, or Netlify, you'll see a mix of the standard header and the vendor-specific one. The comparison below maps the four you meet most often.
Dimension
Cache-Status (RFC 9211)
CF-Cache-Status (Cloudflare)
X-Vercel-Cache (Vercel)
X-Cache (Fastly / Varnish / CloudFront)
Standardized
Yes, IETF RFC 9211
No, vendor-specific
No, vendor-specific
No, de facto Varnish convention
Multi-tier chains
Native, comma-separated list, one entry per cache
Single value, edge only
Single value, edge only
Comma-separated pairs (edge, shield) but semantics vary by vendor
The takeaway: vendor headers cover about 40% of the debugging surface. They tell you hit-or-miss but don't tell you why a miss happened, whether the cache coalesced, or what the remaining TTL was. RFC 9211 covers 100% because it was designed by cache operators who had spent a decade squinting at X-Cache. If your CDN supports emitting it (Fastly, Netlify, and Akamai do; Cloudflare and Vercel do not yet at the time of writing), turn it on and keep the vendor header as a fallback.
Reading multi-layer cache chains
The single feature that makes Cache-Status replace three vendor headers at once is chained emission. Any cache in the request path may append its own entry: origin first, then each proxy, then the edge closest to the user. The rightmost entry is what served the client; earlier entries describe what fed the entry to its right. Consider a realistic three-tier setup: NGINX on origin with an immutable static-asset cache, Fastly as the CDN, and a browser preload path fetched via HTTP 103 Early Hints.
A cold request that misses the edge, misses the shield, and repopulates from origin:
Every cache did the right thing. Origin had the object, shield stored it, edge stored it. The next request from that region will show three hit entries.
A stale-while-revalidate response served instantly from edge with async revalidation:
The ttl=-14 tells you the object expired 14 seconds ago; collapsed tells you the async revalidation was already in flight from a prior request and this one joined it rather than launching another. That's exactly the behavior stale-while-revalidate is designed to produce, and now you can prove it in production without a distributed trace.
A bypass triggered by a session cookie on a page you thought was public:
Now you know why hit-rate dropped after last week's login refactor. Every response carrying Set-Cookie is bypassing the edge. Fix: strip Set-Cookie at the edge for anonymous responses, or move the cookie into a subpath that carves out its own cache namespace.
How do I check if a response is cached?
The fastest check is a raw curl. Ask for headers only and dump the ones that matter:
Run the same command twice. On the second run you should see Cache-Status flip from fwd=miss; stored to hit, and Age should be a small positive integer. If both runs show fwd=miss; stored, the object is not being retained. Check Cache-Control, Vary, and any Set-Cookie attached to the response.
For deeper triage across regions, script curl from a few POPs and diff the results:
#!/usr/bin/env bash
# probe-cache.sh: hit one URL from N POPs, print the Cache-Status header
URL="$1"
for pop in iad sfo lhr syd nrt; do
echo "== $pop =="
curl -sSI -H "X-Debug-POP: $pop" "$URL" \
| awk 'BEGIN{IGNORECASE=1} /^cache-status:|^age:|^server-timing:/'
done
In the browser, DevTools → Network → Headers shows Cache-Status alongside every response. The Chromium 128+ debugger even parses the Structured Field into a table, which is a nice quality-of-life improvement.
For continuous visibility, log the header from RUM. The Resource Timing API exposes response headers only when the origin sends Timing-Allow-Origin: *, but you can pair navigation-timing samples with a beacon that echoes back Cache-Status from the initial HTML document. Bucket beacons by the leftmost hit/fwd token and you get an origin-hit-rate metric for free. See my write-up on TTFB attribution with Server-Timing for the beacon pattern.
Debugging cache misses with fwd reasons
Most "why isn't this cached" tickets I inherit come down to one of the six fwd reasons. Here's the decision tree I use.
fwd=uri-miss on the first request, hit on the second
Normal cold-cache warm-up. Nothing to fix unless first-request TTFB matters for the P95, in which case Early Hints and prewarming crons help.
fwd=uri-miss on every request
Either the response is not cacheable, or the cache key differs each time. Check three things: Cache-Control on the response (a private, no-store, or max-age=0 will kill storage); Set-Cookie on the response (most CDNs bypass automatically); and the URL query string (session tokens or cachebusters mean each request hits a unique key). The key parameter, if your cache emits it, will show the exact cache key it computed.
fwd=vary-miss
The URI matched but no Vary-selected variant matched. This is the "Accept-Language explosion" pattern. Every unique Accept-Language header creates its own variant. Fix: normalize Vary at the edge (drop it, or coerce to a small enum), or move the variance into the URL.
fwd=stale; fwd-status=200
The object expired and the origin returned a full response instead of a 304. Your origin is not respecting the conditional request the cache sent (If-None-Match / If-Modified-Since). Fix the origin to return 304 when the ETag matches. You'll see this flip to fwd=stale; fwd-status=304 and TTFB drop by the body-transfer time.
fwd=bypass
Something explicitly told the cache to skip. Look at detail, then at your CDN's cache rules dashboard. The single most common cause I've seen since 2024 is a Cache-Control: private header set by an unrelated middleware.
fwd=request
The client sent Cache-Control: no-cache or max-age=0. If this is happening for anonymous users, look at your service worker. Some frameworks (Next.js's router.refresh, for one) send no-cache on programmatic navigations.
Correlating Cache-Status with Server-Timing for TTFB attribution
Cache status alone tells you where the byte came from. To tell you how long that took, pair it with the Server-Timing header. Every cache in the chain can emit a Server-Timing metric alongside its Cache-Status entry:
Now you can read TTFB as a story: 6 ms was edge lookup, 118 ms was shield-to-origin revalidation, 0.4 ms was the origin hit. That entire request took ~125 ms and the culprit for anything over 20 ms is the stale check at the shield. If revalidation is dominating your budget across regions, consider a longer s-maxage at the shield or a proactive purge on write instead of TTL-based expiry.
In RUM you want to store both headers together per navigation. A row that reads hit, hit, hit with three sub-10 ms Server-Timing values is a healthy P50. A row that reads fwd=uri-miss on the edge with a 400 ms Server-Timing on origin is the shape of a cold-cache TTFB regression, and it's the one to alert on. Between them you have full attribution of every byte's provenance and every millisecond of its arrival.
Enabling Cache-Status on your origin and edge
Adoption is uneven, so here's a per-platform status check as of mid-2026.
Fastly
Native support since 2022. Emit from VCL with:
sub vcl_deliver {
set resp.http.Cache-Status = "fastly-edge; " +
if(fastly_info.state ~ "HIT", "hit", "fwd=miss") +
"; ttl=" + obj.ttl;
}
Fastly's Compute@Edge (JavaScript) exposes the same primitives via fastly:cache. Turn on the Cache-Status emission in your Delivery service config and it will chain through shield and edge automatically.
NGINX
Not built in, but three lines of Lua or a map block gets you there:
Combined with proxy_cache_lock on; you get accurate collapsed reporting for coalesced upstream fetches.
Cloudflare
No first-class emission yet. The platform still ships CF-Cache-Status. If you run a Worker in front, synthesize the header there. See the Cloudflare cache responses reference for the canonical mapping of CF-Cache-Status tokens to their meanings. My worker maps HIT to "cloudflare"; hit, MISS to "cloudflare"; fwd=uri-miss; stored, BYPASS to "cloudflare"; fwd=bypass, and so on.
Vercel
Vercel's edge cache also does not emit RFC 9211 today. The workaround is identical: an Edge Middleware that translates X-Vercel-Cache to Cache-Status. Because Vercel's ISR uses a stale-while-revalidate model, watch for STALE. That maps to hit; ttl=-N, not to a forward.
Netlify
Netlify emits Cache-Status natively on its Netlify Edge entry alongside its legacy Netlify-Cache-Result. No configuration needed. Check any response served by an Edge Function or a fine-grained cached response.
Varnish
Not shipped by default because Varnish predates the RFC. A short VCL snippet in vcl_deliver that maps obj.hits and obj.ttl to the standard grammar produces compliant output, or install a community VMOD that does the mapping for you.
Frequently Asked Questions
What does Cache-Status: hit mean?
It means a cache in the delivery path, identified by the quoted name preceding the semicolon, served the response directly from its stored copy without contacting a downstream cache or origin. Pair it with the Age and ttl parameters to know how long the object has been cached and how much freshness remains.
What is the difference between Cache-Status and Cache-Control?
Cache-Control is a request or response header that instructs caches on how to behave (max-age, no-store, private, stale-while-revalidate). Cache-Status is a response-only header that describes what a cache actually did in retrospect. One is policy, the other is telemetry. You set the first and read the second.
How do I read a chained Cache-Status header?
Split on commas. Each entry is one cache in the delivery path. The rightmost entry is the cache closest to the client; earlier entries describe caches nearer to origin. If the rightmost shows hit, none of the entries to its left ran for this request (their status is from the earlier request that populated the client-facing cache).
Why is my Cache-Status showing fwd=bypass?
The cache was explicitly told not to store this response. Common causes: Cache-Control: private or no-store from the origin, a Set-Cookie header on the response (many CDNs auto-bypass), a matching cache rule that skips the URL, or an authenticated request the CDN treats as non-shareable. Check detail if present for a vendor-specific reason.
Does Cloudflare support the Cache-Status header?
Not natively as of mid-2026. Cloudflare still emits its proprietary CF-Cache-Status with tokens like HIT, MISS, REVALIDATED, and DYNAMIC. To get RFC 9211 output, run a Cloudflare Worker that reads CF-Cache-Status and rewrites it into the standard header before returning the response.
Can Cache-Status leak sensitive information?
Yes, RFC 9211 explicitly notes the risk. The key parameter can expose cache key composition (including which cookies or headers participate in the key), and detail may reveal internal cache topology. On production endpoints that face untrusted clients, prefer hit/fwd/ttl only, and reserve key and detail for staging or authenticated debug paths.
Client Hints (Sec-CH-DPR, Sec-CH-Width, Critical-CH) let your CDN pick byte-perfect image sizes in 2026. Practical setup, worker code, and real LCP wins from a Chrome-heavy audit.
Tag any image, price, or button with elementtiming and read its real paint timestamp from PerformanceObserver. Practical RUM setup, cross-origin gotchas, and the new Container Timing trial.
Break down Time to First Byte into named backend phases with the Server-Timing header. Includes syntax, CORS gotchas, Node/Workers/nginx examples, and RUM integration.