Cache Debugging and Observability
TL;DR: Diagnosing a caching problem means reading the right header at the right tier — Age tells you how long a shared cache has held a response, X-Cache or CF-Cache-Status tells you whether that tier served a hit, and Vary tells you whether the client and cache are even keying the same request the same way. Combine curl -sI, browser DevTools, and server-side logging to turn a guess into a verified diagnosis.
Quick-reference header block from a healthy CDN-cached response:
HTTP/2 200
Cache-Control: public, s-maxage=3600, max-age=300
Age: 812
CF-Cache-Status: HIT
Vary: Accept-Encoding
ETag: "5f8a3c1b9e0d2"
Mechanism and RFC Alignment
RFC 9111 defines exactly one diagnostic header as normative: Age (§5.1), which a shared cache MUST include on any response it serves from storage. Every other diagnostic signal — X-Cache, CF-Cache-Status, X-Cache-Status, Fastly-Debug — is a vendor extension layered on top of the standard. That distinction matters when debugging: Age behaves identically everywhere because it is specified, while cache-status headers vary in name, value set, and even presence depending on which proxy or CDN sits in the path.
The debugging model has three independent questions, each answered by a different signal:
- Was this response served from a cache, or did it reach the origin? Answered by
X-Cache/CF-Cache-Status/X-Cache-Status— whichever vendor header the CDN or proxy emits. - How long has the cached copy been sitting there, and is it still fresh? Answered by
Agecombined with the effectivemax-ageors-maxageestablished inCache-Control. - Is the client even hitting the same cache entry it expects to? Answered by inspecting
Varyand the request headers it names — a mismatch here produces symptoms (wrong content, oscillating hit/miss) that look like a freshness bug but are actually a cache key problem.
These three questions map directly onto the request path through the cache hierarchy: browser private cache, CDN edge, optional shield tier, and origin. A response can be a hit at one tier and a miss at the next, and only header inspection at each hop reveals which.
Scope and Precedence
Diagnostic headers are visible at different points in the chain, and none of them is guaranteed to survive the full round trip unless explicitly configured to:
| Tier | Header(s) exposed | Notes |
|---|---|---|
| Browser (DevTools) | Network panel status: (from disk cache), (from memory cache), 304 |
Never exposes CDN-side state; independent of Age |
| CDN edge | CF-Cache-Status, X-Cache, Age |
Vendor-specific name and value set |
| Shield / mid-tier cache | X-Cache (per-hop, sometimes chained: HIT, MISS) |
Only present if the CDN chains status headers across tiers |
| Origin | Cache-Control, ETag, Last-Modified, Vary |
The authoritative source values every downstream tier should be interpreting |
Precedence when signals disagree: the origin’s Cache-Control and Vary values are ground truth — if a CDN dashboard claims a HIT but the served body is stale relative to what Cache-Control should allow, trust the header inspection over the dashboard summary, since dashboards often aggregate or sample. Similarly, a browser showing (from disk cache) says nothing about whether the CDN in front of the origin ever cached the response; the two tiers must be checked independently, never inferred from one another. See freshness vs. validation models for how a stale entry differs from a genuinely uncached one.
Diagram: Which Header Answers Which Symptom
Implementation Patterns
Pattern 1: Static asset — clean CDN hit
HTTP/2 200
Cache-Control: public, max-age=31536000, immutable
CF-Cache-Status: HIT
Age: 204981
ETag: "a1c9-static-v42"
A large Age relative to a fresh deploy timestamp is expected for immutable fingerprinted assets — the CDN is correctly holding the object for the full year and never needs to revalidate it.
Pattern 2: API response — deliberate no-store, no cache signal expected
HTTP/2 200
Cache-Control: no-store
Content-Type: application/json
No Age, no X-Cache. This is correct behavior for a response carrying account data, not a bug — the absence of caching headers here is the intended outcome, not a diagnostic gap.
Pattern 3: Vary mismatch — oscillating status for the same URL
# Request A
Accept-Encoding: gzip
→ CF-Cache-Status: HIT Age: 340
# Request B, same URL, seconds later
Accept-Encoding: gzip, br
→ CF-Cache-Status: MISS Age: 0
Both requests target the same path, but Vary: Accept-Encoding means each distinct Accept-Encoding value is a separate cache entry. See using Vary mismatches as a diagnostic signal for how to distinguish this from a genuinely cold cache.
Pattern 4: Multi-tier — shield hit, edge miss
HTTP/2 200
Cache-Control: public, s-maxage=600
X-Cache: MISS, HIT
Age: 88
Some CDNs chain X-Cache values left-to-right by tier. Here the edge PoP missed but the shield tier behind it held a warm copy, so the origin was never touched. Reading only the first token would incorrectly suggest a full miss.
Pattern 5: Auth-gated route — bypassed by design
HTTP/2 200
Cache-Control: private, no-store
CF-Cache-Status: BYPASS
BYPASS (rather than MISS) tells you the CDN recognized the response as non-cacheable by policy — usually a cache rule matching the path or cookie — rather than simply never having seen the URL before.
Server and CDN Configuration
Nginx — log upstream cache status on every request
log_format cache_debug '$remote_addr - $time_local "$request" '
'status=$status upstream_cache_status=$upstream_cache_status '
'age=$upstream_http_age vary=$upstream_http_vary '
'bytes=$body_bytes_sent rt=$request_time';
server {
location / {
proxy_pass http://app_upstream;
proxy_cache my_cache;
add_header X-Cache-Status $upstream_cache_status always;
access_log /var/log/nginx/cache_debug.log cache_debug;
}
}
$upstream_cache_status resolves to HIT, MISS, EXPIRED, STALE, UPDATING, REVALIDATED, or BYPASS for every request Nginx proxies through its own cache zone, giving you an origin-adjacent view independent of whatever CDN sits further upstream.
Apache — expose cache status to logs
LogFormat "%h %l %u %t \"%r\" %>s %b cache=%{X-Cache-Status}o age=%{Age}o" cache_combined
CustomLog "/var/log/apache2/cache_debug.log" cache_combined
Header always set X-Debug-Cache-Status "%{X-Cache-Status}e"
Cloudflare — Cache Analytics and Logpush
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/analytics/dashboard?since=-360&until=0" \
-H "Authorization: Bearer $CF_TOKEN" | jq '.result.totals.requests'
For per-request granularity beyond aggregate analytics, enable Logpush to ship CacheCacheStatus, CacheResponseBytes, and EdgeResponseStatus fields to your own log storage — the dashboard alone cannot answer “which specific requests missed and why.”
Fastly — real-time stats API
curl -s "https://rt.fastly.com/v1/channel/$SERVICE_ID/ts/h" \
-H "Fastly-Key: $FASTLY_TOKEN" | jq '.Data[0].aggregate.hit_ratio'
Interaction with Related Directives
Age and s-maxage/max-age — Age is meaningless without a freshness lifetime to compare it against. An Age of 500 seconds against s-maxage=3600 means the object is fresh with 3100 seconds remaining; the same Age against s-maxage=300 means it is already stale and should have triggered revalidation. Always read the two together — see mastering max-age and s-maxage for the precedence rules that set the denominator.
Vary and cache-status headers — a HIT/MISS oscillation that looks like a flaky cache is frequently a Vary fragmentation problem: the cache key includes a header value the client sends inconsistently. Fixing the underlying Vary list resolves the “instability” without touching TTLs at all.
no-cache and status headers — no-cache permits storage but forces revalidation on every request. Expect to see HIT or REVALIDATED (not MISS) alongside a 304 from origin — a full MISS on every request to a no-cache resource usually means validators (ETag/Last-Modified) are missing or changing unexpectedly on the origin.
Cache hierarchy tiers — reading a status header without knowing which tier emitted it is the single most common debugging mistake. Confirm which hop in the cache hierarchy — browser, edge, shield, or origin proxy — a given header describes before drawing conclusions from it.
Verification Workflow
Step 1 — Baseline header capture
curl -sI https://example.com/path \
| grep -iE 'cache-control|age|x-cache|cf-cache-status|vary|etag'
Run this once to establish the current state before making any changes.
Step 2 — Confirm hit behavior across repeated requests
for i in 1 2 3; do
curl -sI https://example.com/path | grep -iE 'age|x-cache|cf-cache-status'
sleep 2
done
A healthy cache shows a stable HIT with Age climbing by roughly 2 seconds per iteration. A MISS on every iteration means nothing is being stored — check Cache-Control for no-store/private, or Vary for a header the CDN can’t reproduce consistently.
Step 3 — Cross-check in DevTools
- Open the Network tab with “Disable cache” unchecked.
- Reload and inspect the target request’s Headers panel for
Ageand anyx-cache/cf-cache-statusvalues passed through to the browser. - Compare the Size column:
(disk cache)or(memory cache)confirms the browser’s private copy was used — this is independent of whatever the CDN reported on the request that originally populated it.
Step 4 — Isolate a Vary mismatch
curl -sI -H "Accept-Encoding: gzip" https://example.com/path | grep -i cf-cache-status
curl -sI -H "Accept-Encoding: gzip, br" https://example.com/path | grep -i cf-cache-status
If the second call returns MISS for a URL the first call showed as HIT, the response is fragmenting on Accept-Encoding — expected if intentional, a bug if not.
Step 5 — Build lasting observability
One-off curl checks catch a single incident; they don’t catch a hit-rate regression that develops over a week. Two complementary approaches close that gap:
- Server-side logs — ship the
$upstream_cache_status(Nginx) or%{X-Cache-Status}o(Apache) field from the configuration above into your log aggregation pipeline, then chart hit-rate asHIT / (HIT + MISS + EXPIRED + BYPASS)over time, segmented by path. - RUM hit-rate metrics — in the browser, read
performance.getEntriesByType("resource")and correlatetransferSize === 0(served from browser cache, no network transfer) against entries wheretransferSize > 0, then report the ratio to your analytics pipeline. This captures real client-side cache effectiveness that server logs alone cannot see, since it reflects what actually happened in each visitor’s browser rather than only what reached the origin-adjacent proxy.
Together, log-based hit rate (shared-cache effectiveness) and RUM-based hit rate (private-cache effectiveness) give independent, complementary views of the same caching system — a regression showing up in only one of the two immediately narrows the search to that tier.
Failure Modes and Gotchas
- Trusting a CDN dashboard percentage over raw headers — aggregate hit-rate dashboards sample and average; a specific broken URL can be invisible in an aggregate 94% hit rate. Always confirm with a direct
curlagainst the specific path in question. - Reading
X-Cachefrom the wrong hop — when a header is chained (X-Cache: MISS, HIT), reading only the first value inverts the diagnosis. Confirm your CDN’s chaining convention before interpreting multi-token values. - Assuming DevTools reflects CDN state —
(from disk cache)describes the browser only. Always verify the CDN’s own status header separately. Agepresent but stuck at a fixed value — usually means an intermediate proxy is passing through a cached upstream response without incrementingAgeitself, a common misconfiguration in layered Varnish/Nginx stacks. Per RFC 9111 §5.1, every cache in the chain must incrementAge, not just the first one.- Logging cache status but not
Vary— a hit-rate drop with no obvious cause is frequently aVary-driven key fragmentation. If your log format (as configured above) omits$upstream_http_vary, add it — you cannot diagnose fragmentation retroactively without it. - No baseline before debugging — capturing headers only after a user reports a problem means you have no “before” state to diff against. Establish a recurring baseline capture (Step 1) for critical paths, not just an on-demand one.
- Confusing
BYPASSwithMISS—BYPASSindicates the CDN deliberately skipped caching by rule; treating it as a caching failure to “fix” often means undoing an intentional security or personalization rule.
Frequently Asked Questions
What’s the difference between X-Cache and CF-Cache-Status?
X-Cache is a generic, non-standard header used by Varnish, Squid, and many reverse proxies, typically HIT/MISS with an optional hostname suffix per tier. CF-Cache-Status is Cloudflare’s own equivalent with a richer value set (HIT, MISS, EXPIRED, BYPASS, DYNAMIC, REVALIDATED). Both serve the same purpose but are vendor-specific — see interpreting X-Cache and CF-Cache-Status for the full value reference.
Why does the Age header reset to zero after every request?
Age staying at 0 on every request means no shared cache is actually storing the response — traffic is reaching the origin every time, or no-store/private is preventing storage, or a Vary mismatch is scattering requests across cache entries that never accumulate hits. Confirm which by reading the Age header guide’s RFC 9111 computation model.
How do I tell a Vary mismatch from an ordinary cache miss?
An ordinary miss is consistent — MISS until the cache warms, then a stable HIT with climbing Age. A Vary mismatch oscillates: the same-looking URL alternates HIT/MISS because it’s actually landing on different cache entries keyed by a header the client sends inconsistently.
Can I get cache hit-rate metrics without CDN-level analytics access?
Yes. RUM captures hit rate from the browser’s Resource Timing API independent of any CDN dashboard, and origin-side Nginx or Apache logs capture upstream cache status independent of CDN access entirely — both approaches are described in the Verification Workflow above.
Does Chrome DevTools “from disk cache” prove the CDN cached the response?
No. It describes only the browser’s private cache. The CDN in front of the origin must be checked separately via CF-Cache-Status or X-Cache — the two tiers operate independently and one can hit while the other misses.
Related
- Public vs Private Cache Scope — determines whether a response is eligible for shared-cache storage at all before any status header becomes meaningful.
- How CDN Cache Keys Are Generated — the key structure underlying every HIT/MISS decision a status header reports.
- Conditional Requests and 304 Responses — the revalidation flow that produces
REVALIDATEDstatus values instead of a fullMISS. - Header Stacking and Directive Precedence — resolves which directive governs the freshness lifetime that
Ageis measured against.