Debugging Cache Headers With curl: A Practical Walkthrough

 

 

You can prove what your CDN is actually caching in about 20 minutes with nothing but curl and a terminal. Send the same request twice, three seconds apart, and compare the curl headers: if the Age value climbs by roughly three and the cache status header flips from MISS to HIT, the edge served the second response from cache. If Age stays at 0 across ten requests, nothing is being cached, whatever the dashboard says. This playbook gives you the exact commands to debug cache headers, read the curl result correctly, and calculate the TTL your objects really have.

Terminal session using curl to debug cache headers and check cache hit status on a CDN response

What you need before you debug cache headers with curl

  • curl 7.72 or newer (7.72 added reliable HTTP/2 header dumping; 8.x if you want the --http3 flag compiled in).
  • One URL that should be cacheable: a static asset, an image, a manifest segment. Use a real path, not the homepage.
  • Permission to purge that one object, or a path you can safely dirty.
  • A shell that can write a scratch file. Every command below writes the body to body.bin so the response headers stay readable.

Substitute EXAMPLE_DOMAIN with your hostname and EDGE_IP with an address your hostname resolves to. Nothing else needs editing.

Step by step: debug cache headers with curl and check cache hit status

Step 1: capture the full curl result, not just the body

curl -sS -D - -o body.bin https://EXAMPLE_DOMAIN/assets/app.js

The -D - flag dumps response headers to stdout while -o sends the body to a file. You get the status line, every response header, and no 400 KB of minified JavaScript in your scrollback.

Step 2: read the cache status header the CDN actually sends

Cache status header names are not standardized. Scan the curl result for x-cache, x-cache-status, cf-cache-status, cdn-cache, or x-served-by. Values are usually HIT, MISS, EXPIRED, REVALIDATED, BYPASS or DYNAMIC. BYPASS and DYNAMIC are the interesting ones: they mean a rule explicitly disqualified the object, not that the cache was cold.

Also record Cache-Control, Age, ETag, Last-Modified, Vary, and any Set-Cookie. Those six headers explain almost every caching bug you will hit.

Step 3: check cache hit status with two requests and Age arithmetic

curl -sS -D - -o body.bin https://EXAMPLE_DOMAIN/assets/app.js
sleep 3
curl -sS -D - -o body.bin https://EXAMPLE_DOMAIN/assets/app.js

The HTTP Age response header, defined in RFC 9111, is the most portable proof of a cache hit across CDNs: if two curl requests sent three seconds apart return Age values that differ by roughly three, the second response came from a stored copy rather than the origin. As of 2026 the vendor-specific status header names still differ (x-cache, cf-cache-status, x-cache-status), but Age is standard and behaves the same everywhere.

Step 4: calculate the TTL your object really has

Read Cache-Control from the curl headers. If s-maxage is present, it overrides max-age for shared caches, and max-age applies only to the browser. Remaining edge TTL is s-maxage minus Age. An object with s-maxage=86400 and Age: 82800 has 1,400 seconds left, which is why your 5-minute test loop showed a sudden MISS.

If Age keeps growing past s-maxage while the status header reads HIT, you are being served stale content under stale-while-revalidate or stale-if-error. That is usually correct behavior, not a bug.

Step 5: pin the test to one edge node

curl -sS -D - -o body.bin --resolve EXAMPLE_DOMAIN:443:EDGE_IP https://EXAMPLE_DOMAIN/assets/app.js

Without --resolve, consecutive requests can land on different nodes, and a cold second node returns MISS even though caching works perfectly. This single flag removes the most common false negative in CDN debugging.

Step 6: expose Vary traps with Accept-Encoding

curl -sS -D - -o body.bin -H 'Accept-Encoding: br' https://EXAMPLE_DOMAIN/assets/app.js
curl -sS -D - -o body.bin -H 'Accept-Encoding: gzip' https://EXAMPLE_DOMAIN/assets/app.js

Each encoding is a separate cache entry when Vary includes Accept-Encoding. That is fine. Vary on User-Agent or on a cookie header is not: it fragments one object into thousands of variants and drives hit ratio toward zero. If you see Vary listing anything beyond Accept-Encoding, you have found your problem.

Step 7: confirm revalidation returns 304

curl -sS -D - -o body.bin -H 'If-None-Match: "ETAG_VALUE"' https://EXAMPLE_DOMAIN/assets/app.js

Paste the ETag from step 1, quotes included, into ETAG_VALUE. A correct stack answers 304 Not Modified with no body. A 200 means the ETag is not surviving the edge, often because compression rewrites it without marking it weak, and every revalidation becomes a full origin transfer.

Validation: what a correct curl result looks like

  • First request: cache status MISS or EXPIRED, Age: 0 or absent.
  • Second request within TTL: status HIT, Age greater than 0 and increasing at wall-clock rate.
  • Cache-Control contains public and either max-age or s-maxage above 60.
  • No Set-Cookie on the cacheable response.
  • Conditional request returns 304 with the same ETag.

If all five hold on a pinned node, the object is cached and you can stop guessing.

Clean up after the test

Remove any cache-busting parameters you added to the test URL, purge the single object you dirtied, and revert temporary origin header changes before you walk away. If you added a debug header at the origin to trace requests, delete it in the same commit that added it. Leaving a Cache-Control: no-store on one path is a quiet way to double your origin egress for a month.

Failure modes: symptom, cause, fix

Symptom in the curl result Likely cause Fix
Age always 0, status always MISS Set-Cookie on the response, or Cache-Control private / no-store Strip Set-Cookie for static paths at the origin; send public with s-maxage
MISS on every second request, HIT occasionally Requests landing on different edge nodes Repeat with --resolve pinned to one address
HIT with HEAD, MISS with GET HEAD served from a separate or synthesized entry Test with GET plus -o only; treat HEAD results as advisory
Conditional request returns 200, never 304 ETag rewritten by edge compression, or dropped entirely Emit weak ETags, or fall back to Last-Modified validation
Hit ratio collapses after a deploy Query parameters or a new header included in the cache key Normalize the cache key: ignore marketing parameters, sort the rest

Most reported caching failures are not TTL problems at all: they are cache key and Set-Cookie problems that a two-request curl test surfaces in under a minute.

Tuning: turn the check into a repeatable measurement

for n in 1 2 3 4 5 6 7 8 9 10
do
curl -sS -o body.bin -w "%{http_code} ttfb:%{time_starttransfer}s total:%{time_total}s\n" --resolve EXAMPLE_DOMAIN:443:EDGE_IP https://EXAMPLE_DOMAIN/assets/app.js
sleep 1
done

Time-to-first-byte separates a genuine edge hit from a warm origin. On a pinned node in 2026 measurements, an edge hit typically returns TTFB in the 5 to 40 ms range from a nearby client, while an origin fetch through the same edge usually lands between 80 and 400 ms depending on origin distance. A status header claiming HIT alongside a 250 ms TTFB deserves a second look.

Why HEAD requests can lie when you debug cache headers with curl

curl -I sends HEAD, and some edges answer HEAD from metadata, synthesize it from a stored object, or forward it to origin regardless of cache state. The curl headers look authoritative and describe an object nobody actually served. Use GET with the body discarded to a file. It costs bandwidth and buys truth.

What curl cannot tell you

curl gives you one client, one node, one moment. It cannot report fleet-wide hit ratio, tell you whether a shield tier absorbed the miss, or show how many distinct cache keys one URL generates across real user traffic. For those you need CDN logs and origin request counts, and the honest trade-off is that curl will happily show a clean HIT while your global hit ratio sits at 60% because of key fragmentation you cannot see from one terminal.

When the headers are provably correct and the edge still misses, the remaining variable is configuration: how the CDN builds its cache key and whether you can override TTL per path. BlazingCDN exposes per-path cache rules and cache key control, which makes the Age arithmetic above a direct verification of the rule you just changed rather than a guess; the specifics are listed in the CDN cache control and TTL feature reference.

FAQ: debugging cache headers with curl

How do I check if a CDN response is a cache hit using curl?

Send the same GET request twice a few seconds apart and compare the Age header in the curl result. A hit returns a non-zero Age that increases at wall-clock rate, usually alongside a vendor header reading HIT. Pin both requests to one edge address with --resolve, otherwise a second, colder node will report a false MISS.

Why does curl show Cache-Control but the object is never cached?

Because Cache-Control is only one input to the caching decision. A Set-Cookie header on the response, a Vary header listing a high-cardinality field, an Authorization request header, or a cache key that includes query parameters will each prevent storage regardless of a valid max-age. Check those four before touching TTL values.

What is the difference between max-age and s-maxage in curl headers?

max-age sets the freshness lifetime for all caches, while s-maxage overrides it for shared caches such as CDNs and proxies and is ignored by browsers. A common production pattern is a short max-age for clients and a long s-maxage for the edge, giving fast invalidation at the browser and high edge hit ratio at the same time.

Can curl test HTTP/3 cache behavior?

Yes, if your curl build includes HTTP/3 support: add the --http3 flag and the same header inspection applies. Caching semantics are transport-independent per RFC 9111, so Age, Cache-Control and ETag behave identically. What changes is connection setup timing, which affects TTFB comparisons but not whether an object was stored.

Run this against one production path this week

Pick your single highest-traffic static asset, pin it with --resolve, and run the ten-request loop. Write down four numbers: cache status on request one, Age on request two, remaining TTL from s-maxage minus Age, and median TTFB across the ten. If Age never moves, you just found origin traffic you are paying for twice. If it moves but TTFB stays above 100 ms, you have a routing question, not a caching one. Both answers take under five minutes and neither requires a dashboard.