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:

  1. 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.
  2. How long has the cached copy been sitting there, and is it still fresh? Answered by Age combined with the effective max-age or s-maxage established in Cache-Control.
  3. Is the client even hitting the same cache entry it expects to? Answered by inspecting Vary and 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

Cache Debugging Decision Tree A decision tree starting from "response feels slow or stale" that walks through checking for X-Cache or CF-Cache-Status presence, HIT status, Age versus max-age, and Vary-based content mismatches, ending in one of several diagnostic conclusions. Response feels slow or stale X-Cache / CF-Cache-Status present in response? No No shared cache in path — inspect Cache-Control on the origin response directly Yes Status reads HIT? (vs MISS / EXPIRED / BYPASS) No Genuine cache miss — check Cache-Control precedence and Vary for fragmentation Yes Age within max-age / s-maxage window? No Stale-serving in effect — check stale-while-revalidate / stale-if-error, or a CDN bug Yes Content still wrong for this client? Yes Check Vary — likely serving the wrong cached variant for this request No Cache is healthy — look at origin logic or client rendering

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'

Age and s-maxage/max-ageAge 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 headersno-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

  1. Open the Network tab with “Disable cache” unchecked.
  2. Reload and inspect the target request’s Headers panel for Age and any x-cache/cf-cache-status values passed through to the browser.
  3. 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 as HIT / (HIT + MISS + EXPIRED + BYPASS) over time, segmented by path.
  • RUM hit-rate metrics — in the browser, read performance.getEntriesByType("resource") and correlate transferSize === 0 (served from browser cache, no network transfer) against entries where transferSize > 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

  1. 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 curl against the specific path in question.
  2. Reading X-Cache from 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.
  3. Assuming DevTools reflects CDN state(from disk cache) describes the browser only. Always verify the CDN’s own status header separately.
  4. Age present but stuck at a fixed value — usually means an intermediate proxy is passing through a cached upstream response without incrementing Age itself, a common misconfiguration in layered Varnish/Nginx stacks. Per RFC 9111 §5.1, every cache in the chain must increment Age, not just the first one.
  5. Logging cache status but not Vary — a hit-rate drop with no obvious cause is frequently a Vary-driven key fragmentation. If your log format (as configured above) omits $upstream_http_vary, add it — you cannot diagnose fragmentation retroactively without it.
  6. 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.
  7. Confusing BYPASS with MISSBYPASS indicates 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.


Back to Core Caching Fundamentals & HTTP Lifecycle