Age and Date Headers for Edge Cache Debugging
A user reports old markup, a colleague cannot reproduce it, and the response you get from curl looks perfectly correct. Somewhere between your origin and that user’s browser sits a copy of the response that nobody has looked at. Age and Date are the two headers that locate it, and the provider cache-status headers name the tier that is holding it. Read together they answer the only question that matters during an incident: is this stale response coming from your origin, from a cache you control, from a cache the CDN controls, or from the browser itself?
What Age Actually Counts
Age is the cache’s own estimate, in seconds, of how long it has been since the response was generated — or last successfully revalidated — at the origin server. It is set by whichever cache answers your request, and it is a single non-negative integer.
The subtlety that trips people up is that Age is cumulative across tiers, not per-hop. When a cache stores a response it records how long it has held it, and when it forwards that response it emits an Age equal to its own residence time plus whatever Age it received from upstream. So a two-tier CDN with an origin shield in front of the edge produces an Age that describes the whole chain, and gives you no way to attribute the seconds to a particular tier from the number alone.
An origin server does not send Age. If you see Age: 0 or no Age header at all, either the response came straight from the origin or the cache stored it within the last second.
Date Is Not Last-Modified
Date records when the origin generated the response message. Caches forward it unchanged, which is what makes it useful: for a cached response, the wall-clock gap between now and Date should be approximately equal to Age. That equality is the sanity check that tells you the arithmetic is trustworthy.
expected_age = now - Date
Age ≈ expected_age → the object has been cached since Date, and clocks agree
Age ≈ 0, Date ≈ now → this response came from the origin
Age ≈ 0, Date old → a cache revalidated and reset Age, or clocks are skewed
Age > 0, Date ≈ now → an intermediary is rewriting Date; trust Age alone
A few seconds of divergence is ordinary clock skew between your machine and the origin. Minutes of divergence means one of the two clocks is wrong, and every conclusion you draw from Date after that is wrong with it.
Last-Modified answers a different question entirely: when the selected representation last changed. It is about the file, not the response. For a fingerprinted asset it is close to meaningless as a freshness signal, because it reflects the upload timestamp — a redeploy that rewrites byte-identical files bumps Last-Modified on every one of them while the content hash in the filename stays put. That is the same asymmetry that makes filename fingerprinting a stronger validator than any timestamp, argued in full under ETag versus immutable Cache-Control.
Header Reference
| Header | Emitted by | What it proves |
|---|---|---|
Age |
The cache that answered you | Total seconds the chain has held this copy |
Date |
Origin, forwarded unchanged | When the response was generated upstream |
Last-Modified |
Origin | When the file changed on disk; not a cache signal |
Cache-Status |
Any RFC 9211 cache in the path | Per-cache hit or forward, with reason and remaining TTL |
CF-Cache-Status |
Cloudflare edge | HIT, MISS, EXPIRED, STALE, REVALIDATED, BYPASS, DYNAMIC |
X-Cache |
Fastly and Varnish; also CloudFront | Per-node verdict list, or Hit from cloudfront |
X-Cache-Hits |
Fastly and Varnish | How many times each node has already served it |
X-Served-By |
Fastly and Varnish | Which nodes handled it, shield first, edge last |
X-Amz-Cf-Pop |
CloudFront | Which point of presence answered |
X-Cache-Status |
Your own Nginx, via $upstream_cache_status |
Your proxy layer’s verdict, independent of the CDN |
Cache-Status is the standard version of all of the vendor headers, and it carries far more than a hit or miss. Each cache contributes one member, identified by a name it chooses:
Cache-Status: "shield-lhr"; hit; ttl=1200, "edge-lhr"; fwd=stale; fwd-status=304; ttl=86400
A member is either a hit or a fwd with a reason. The reasons that matter in practice are uri-miss (nothing stored under this key), vary-miss (something stored, but not for this variant — see Vary header pitfalls with fingerprinted assets), stale (stored but expired, so revalidated), bypass (a rule told the cache not to serve from store) and request (the client asked for a fresh copy). The ttl parameter is the remaining freshness lifetime in seconds, fwd-status is what the upstream answered, and collapsed tells you the request was merged with an in-flight fetch. Caches append their member as the response travels back, so the leftmost member is the cache nearest the origin and the rightmost is the one that answered you. Most providers still emit a single member or none at all, so treat the vendor headers as the practical fallback.
Cloudflare’s vocabulary needs one clarification: EXPIRED means the edge had the object, found it stale, and went to origin for a new one; REVALIDATED means it went to origin and got a 304, so the bytes you received are the ones it already had. STALE means it served an expired copy deliberately, which happens under stale-while-revalidate or when the origin is unreachable. Fastly’s X-Cache: MISS, HIT reads along the request path — the shield missed, the edge hit — and X-Cache-Hits: 0, 14 gives the per-node hit counters in the same order. CloudFront’s RefreshHit from cloudfront is the equivalent of Cloudflare’s REVALIDATED.
Browser Hit, Edge Hit, or Origin Hit
The fourth row deserves the most attention because it is invisible to command-line tooling. A browser cache hit produces no request, so there is nothing for curl to read and nothing for the CDN to log. Confirm it from the page instead:
// Paste into the DevTools console after loading the page
performance.getEntriesByType('resource')
.filter((entry) => /\.(js|css)$/.test(new URL(entry.name).pathname))
.forEach((entry) => {
const local = entry.transferSize === 0 && entry.decodedBodySize > 0;
console.log(
(local ? 'browser cache' : 'network').padEnd(14),
Math.round(entry.duration) + 'ms',
entry.name
);
});
One caveat: transferSize is reported as 0 for cross-origin resources unless the response carries Timing-Allow-Origin, so add that header on your CDN before trusting the output for assets served from a separate hostname.
The One-Liner and the Sampling Loop
A single inspection is one curl away. Prefer a GET with discarded body over -I, because some origins and edge configurations handle HEAD on a different code path:
curl -sS -o /dev/null -D - https://cdn.example.com/assets/app.3f8d2a1c.js \
| grep -iE '^(date|age|last-modified|cache-control|etag|cache-status|cf-cache-status|x-cache|x-cache-hits|x-served-by|x-amz-cf-pop):'
One sample tells you what one node did once. Behaviour over time is what distinguishes a healthy long-lived edge object from an edge that is evicting the object as fast as you can request it, and that needs repeated sampling:
#!/usr/bin/env bash
# scripts/edge-sample.sh — sample one URL repeatedly and print the cache picture
# Usage: ./scripts/edge-sample.sh https://cdn.example.com/assets/app.3f8d2a1c.js 10 30
set -euo pipefail
URL="${1:?usage: edge-sample.sh <url> [samples] [interval-seconds]}"
SAMPLES="${2:-10}"
INTERVAL="${3:-30}"
field() {
printf '%s' "$1" | awk -v k="$2" '
tolower($1) == k ":" { $1 = ""; sub(/^[ \t]+/, ""); gsub(/\r/, ""); print; exit }'
}
printf '%-10s %-8s %-12s %-22s %s\n' CLOCK AGE STATUS SERVED-BY DATE
i=0
while [ "$i" -lt "$SAMPLES" ]; do
headers=$(curl -sS -o /dev/null -D - "$URL")
age=$(field "$headers" age)
origin_date=$(field "$headers" date)
served_by=$(field "$headers" x-served-by)
status=$(field "$headers" cf-cache-status)
[ -z "$status" ] && status=$(field "$headers" x-cache)
[ -z "$status" ] && status=$(field "$headers" cache-status)
printf '%-10s %-8s %-12s %-22s %s\n' \
"$(date -u +%H:%M:%S)" \
"${age:--}" \
"${status:--}" \
"${served_by:--}" \
"${origin_date:--}"
i=$((i + 1))
if [ "$i" -lt "$SAMPLES" ]; then
sleep "$INTERVAL"
fi
done
Worked Example
Five samples thirty seconds apart against a fingerprinted bundle on Fastly:
CLOCK AGE STATUS SERVED-BY DATE
14:02:11 1140 HIT cache-lhr6134-LHR Mon, 29 Jul 2026 13:43:11 GMT
14:02:41 1170 HIT cache-lhr6134-LHR Mon, 29 Jul 2026 13:43:11 GMT
14:03:11 1200 HIT cache-lhr6134-LHR Mon, 29 Jul 2026 13:43:11 GMT
14:03:41 0 MISS cache-lhr6131-LHR Mon, 29 Jul 2026 14:03:41 GMT
14:04:11 30 HIT cache-lhr6131-LHR Mon, 29 Jul 2026 14:03:41 GMT
Three readings hold together. Age rises by exactly thirty seconds per sample, which means one node is holding one copy and no intermediary is resetting the counter. Date never moves, which confirms nothing is rewriting it and that the copy really was generated at 13:43:11. And 14:02:11 minus 13:43:11 is 1140 seconds, matching Age to the second, so your clock and the origin’s clock agree.
The fourth sample looks alarming and is not. X-Served-By changed: the request landed on a sibling node in the same point of presence, that node had never seen the object, so it fetched from the shield or origin and reported Age: 0 with a fresh Date. The fifth sample shows the new node counting up from there. Nothing was evicted; a load balancer simply moved you. This is why X-Served-By belongs in the sample output — without it, samples four and five look like an eviction bug.
Is This the CDN’s Fault or Mine?
Work the checks in this order and stop at the first one that fires.
- Is the URL fingerprinted? If it is, and the body is old, the URL itself is old — the browser was told to fetch it by a stale HTML entry point or manifest. The CDN is doing exactly what it was asked. Look at the document, not the asset.
Age: 0with a miss status and old bytes. The origin is serving the old file. Your upload did not land, or landed in the wrong prefix. Nothing about the CDN is involved.Ageabove zero, a hit status, old bytes, unhashed URL. Working as designed. This is the case fingerprinting exists to eliminate; until then you need a targeted purge, covered under purging Cloudflare cache by URL versus cache tag.Agelarger than your ownmax-age. Either the edge is deliberately serving stale under stale-while-revalidate, or yourCache-Controlnever reached the edge in the first place. Compare theCache-Controlon the wire with the one in your config before blaming the cache.Ageresets to zero on every sample with a miss. The edge is not retaining the object. Check for aVaryon a high-cardinality header, cookies on the asset response, or a cache rule marking the path ineligible.curlshows the new bytes but the browser shows old ones. Nothing on the wire can explain this. It is the browser’s HTTP cache or a service worker, and the resource-timing snippet above is how you confirm which.
When to Reconsider
For a correctly fingerprinted asset, a large Age is the goal, not a symptom. Chasing it wastes an incident: the entire point of a content-hashed URL is that the object at it never changes, so a copy stored six weeks ago is exactly as correct as one stored six seconds ago. Reserve this workflow for URLs whose meaning is allowed to change — HTML documents, asset manifests, service worker scripts, unhashed favicons.
The arithmetic also degrades when the chain is long or opaque. Corporate proxies, ISP caches and some security appliances add residence time to Age without identifying themselves, and a small number of providers strip Age on the way out. Where you have more than two tiers you control, emit your own Cache-Status member from each of them; a named per-cache record beats reverse-engineering a single cumulative integer.
Frequently Asked Questions
Why does Age sometimes exceed my max-age?
Because a cache is allowed to serve an expired response under stale-while-revalidate or stale-if-error, and because CDNs commonly apply their own edge TTL independently of the max-age you send to browsers. Compare the Age against the edge TTL configured in the provider dashboard, not against the browser-facing directive.
Does a 304 Not Modified reset Age to zero?
Yes. A successful revalidation makes the stored response fresh again, and the cache recomputes Age from the moment of that validation rather than from the original fetch. This is why a REVALIDATED or RefreshHit status alongside a small Age is not evidence that the object was re-downloaded.
Can I trust Age when the response passed through two CDNs?
Only as a total. Each cache adds its residence time to whatever arrived, so with two providers stacked you get one number covering both and no way to split it. Multi-provider setups are exactly where the per-cache Cache-Status members earn their keep, as discussed under purging two CDNs without a split-brain cache.
Should I add Age to my own Nginx responses?
Only if Nginx is acting as a caching proxy, in which case it emits Age for cached responses automatically. Do not synthesise the header on an origin that serves files from disk — a fabricated Age makes every downstream cache miscalculate freshness, and it destroys the one arithmetic check that makes this debugging workflow reliable.
Related
- Fingerprinting in HTTP headers — up: the full response-header contract for fingerprinted assets
- ETag versus immutable Cache-Control — choosing between revalidation and a one-year promise
- Vary header pitfalls with fingerprinted assets — why an edge refuses to retain an object it just stored
- Stale-while-revalidate for HTML entry points — the directive that legitimately pushes Age past max-age
- Origin shield hit ratio versus purge latency — what the extra tier contributes to the Age you observe