How to Diagnose CDN Cache Misses from Response Headers

This guide gives CDN Edge Caching Configuration a debugging method, within Advanced Caching Strategies & CDN Architecture. When TTFB is slow and you suspect the CDN is not caching, the answer is almost always in the response headers. CDNs report cache outcomes (hit, miss, expired, bypass, dynamic), and the origin's caching headers, cookies and Vary explain why. Reading them systematically turns "the cache isn't working" into a specific cause in a few minutes.

The vocabulary differs by vendor — cf-cache-status at Cloudflare, x-cache at CloudFront and Fastly, x-cache-status or Akamai-Cache-Status elsewhere — but the questions are the same: Was the response eligible for caching? Was it in the cache? If it was, was it fresh? And if it was not eligible, which header or rule prevented it?

Reading a cache miss Decision sequence for diagnosing why a CDN response was not a cache hit using response headers. Reading a cache miss Status says BYPASS or DYNAMIC? Not eligible — check Cache-Control, Set-Cookie and CDN rules yes no Status says MISS repeatedly for the same URL? Key fragmentation or Vary — compare cache keys yes no Status says EXPIRED / REVALIDATED? TTL too short — check s-maxage and Age yes no HIT — look elsewhere (origin of the slowness is not the cache)

Rapid Diagnosis

  • Request the same URL twice with curl -sI and compare cache status. A second MISS is the most informative signal.
  • Read Age. On a hit, Age shows how long the object has been cached; Age: 0 on every request means it is never reused.
  • Read Cache-Control. private, no-store, no-cache or a missing s-maxage/max-age usually explain BYPASS or DYNAMIC.
  • Look for Set-Cookie. Many CDNs will not cache responses that set cookies.
  • Read Vary. Values like Cookie, User-Agent or Accept-Language fragment the cache.

Root Cause Analysis

1. Ineligible by headers. The origin forbids shared caching (private, no-store) or gives no TTL, and the CDN's default for that content type is not to cache.

2. Ineligible by rules. CDN configuration bypasses certain paths, methods (POST), or requests with cookies or Authorization.

3. Eligible but not found. The key differs each time (query parameters, Vary) or the object was evicted or purged.

4. Found but stale. The TTL expired; the CDN revalidated or refetched from origin.

Cache status headers by vendor How major CDNs report cache outcomes in response headers. Cache status headers by vendor CDN Header Common values Cloudflare cf-cache-status HIT, MISS, EXPIRED, BYPASS, DYNAMIC CloudFront x-cache Hit from cloudfront, Miss from cloudfront Fastly x-cache (+ x-cache-hits) HIT, MISS (per tier) Akamai x-cache (debug headers) TCP_HIT, TCP_MISS Any Age seconds in cache

Step-by-Step Resolution

1. Capture headers for repeated requests

bash
for i in 1 2 3; do
  curl -s -o /dev/null -D - 'https://www.example.com/products/42' \
    | grep -iE '^(cf-cache-status|x-cache|age|cache-control|vary|set-cookie|cdn-cache-control):'
  echo ---
done
# trade-off: curl sends no cookies by default. Repeat with the cookies a real
# browser sends (copy as cURL from DevTools), because cookies often cause bypass.

Expected outcome: a clear pattern — hits with growing Age, or misses with a visible cause.

2. Fix eligibility first

If the status is BYPASS/DYNAMIC: add explicit shared-cache headers (s-maxage or a CDN-specific header such as CDN-Cache-Control/Surrogate-Control), remove Set-Cookie from cacheable responses, and check CDN rules for path or cookie bypasses.

3. Fix key fragmentation

If repeated requests for "the same" URL miss, compare the exact URLs and request headers involved. Tracking parameters and Vary are the usual causes — see normalizing cache keys to raise hit rate.

4. Fix freshness

If responses expire too quickly, lengthen s-maxage and add stale-while-revalidate, relying on purges for correctness rather than short TTLs.

Causes of HTML misses found in one audit Bar chart of the causes of HTML cache misses found by reading response headers across a site's top templates. Causes of HTML misses found in one audit Set-Cookie on HTML 38% Tracking params in key 27% Cache-Control: private 19% TTL expired (60s) 11% Other 5%

Verification

After each fix, repeat the curl loop: the second and later requests should be hits with increasing Age. Add the cache status to Server-Timing so RUM can chart hit ratio as experienced by real users (as described in exposing backend timing with Server-Timing headers), and track TTFB p75 split by status.

A content site's HTML hit ratio was 12% despite Cache-Control: public, s-maxage=600. Headers showed cf-cache-status: DYNAMIC and a Set-Cookie: session=… on every response — the framework refreshed an anonymous session on every page view. Moving session creation to the first interaction that needed it (comment posting) removed the header from page responses; the next curl loop showed MISS then HIT with growing Age. The hit ratio rose to 88% and TTFB p75 dropped from 520ms to 95ms.

Common Mistakes

  • Testing only without cookies. Real browsers send cookies that can trigger bypass rules.
  • Trusting the CDN dashboard average. Aggregates hide per-template problems; inspect headers per template.
  • Confusing browser cache with CDN cache. DevTools "from disk cache" says nothing about the CDN; use the CDN's status header.
  • Debugging through a single PoP. Results differ by location; test from the regions your users are in.

Edge Cases

Multiple cache layers. A response can be a CDN hit but an origin-proxy miss, or vice versa; each layer may add its own status header. Read them all.

Tiered caches. Some CDNs report hits from the parent tier differently (for example, Fastly's per-tier x-cache values); a "MISS, HIT" pair means the edge missed and the shield hit.

Revalidated responses. REVALIDATED or 304 interactions with the origin are cheaper than full misses but still pay origin latency.

Debug headers. Many CDNs offer extra debug headers on request (Akamai Pragma headers, Fastly debug); enable them in staging for detailed reasons.

FAQ

What does DYNAMIC mean on Cloudflare?

The response was not eligible for caching under the current rules — typically HTML or JSON without a rule to cache it, or a response with cache-preventing headers. It is not a miss; it was never going to be cached.

Why is Age missing on hits?

Some CDNs omit Age when it is zero or when serving from certain tiers. Use the status header as the primary signal and Age as a secondary one.

Should Cache-Control for browsers and CDNs differ?

Often, yes. Use s-maxage (or a CDN-specific header) to give the CDN a long TTL you can purge, and a short or zero max-age for browsers, which you cannot purge.

How do I see cache status in real users' sessions?

Expose it via Server-Timing (for example cdn-cache;desc=HIT), which the browser makes available to JavaScript through Resource and Navigation Timing, then include it in your RUM beacon.

Can a CDN cache responses to requests with cookies?

Yes, if configured to; whether it should depends on whether the response varies by cookie. For shared content, configure the CDN to ignore cookies for those paths and make sure the origin does too.

Can a response be a hit but still slow?

Yes. A hit removes origin latency, but transfer of a large response, slow TLS setup, or an edge function doing work after the cache lookup can still make TTFB high. If the status is HIT and TTFB is poor, look at connection timing and edge processing instead of caching.

Which headers should I log at the edge for later analysis?

Cache status, Age, the computed cache key (or a hash of it), response Cache-Control, whether Set-Cookie was present, and the PoP or region. With those in access logs, most miss investigations can be done with a query rather than by reproducing requests manually.

What is the fastest way to check many URLs?

Script the curl loop over a list of representative URLs per template, run it twice, and tabulate status, Age, Cache-Control, Vary and Set-Cookie for the second pass. A spreadsheet of fifty URLs usually reveals the dominant cause in minutes, and the script doubles as a regression check after configuration changes.