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.

How Age accumulates on the way back The origin emits a response with a Date header and no Age. The origin shield has held it for 900 seconds and emits Age 900 with the Date unchanged. The edge point of presence has held it for a further 240 seconds and emits Age 1140. The browser reads Age 1140 alongside the original Date. How Age accumulates on the way back response path, origin to browser Origin generates response no Age header Date: 12:00:00 Origin shield held 900 s Age: 900 Date unchanged Edge PoP held 240 s more Age: 1140 Date unchanged Browser reads the pair Age: 1140 Date: 12:00:00 Each cache adds its own residence time to the Age it received. Age 1140 with Date 12:00:00 means this copy left the origin nineteen minutes ago.
The number you read at the browser describes the whole chain; only a per-cache status header tells you which tier contributed which seconds.

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

Reading three values at once A four-row matrix. Age zero with a miss and current bytes means the origin answered. A climbing Age with a hit and pre-deploy bytes means the edge stored the object before the deploy. Age zero with a miss and pre-deploy bytes means the origin itself is stale. No request on the wire at all means the browser or a service worker answered. Reading three values at once Age Cache status Response body What it tells you 0 MISS current bytes The origin answered this request your deploy is live climbing HIT pre-deploy bytes The edge stored this before you deployed, which is normal 0 MISS pre-deploy bytes The origin itself is stale the upload never landed not sent no request on the wire pre-deploy bytes Browser disk cache or a service worker; curl cannot see it
The bottom row is the one people miss: if nothing reaches the wire, no response header can explain the staleness.

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.

Age over repeated samples The left chart shows six samples in which Age climbs steadily from 20 to 170, indicating one node retaining one copy. The right chart shows six samples in which Age repeatedly resets to a low value, indicating the edge is not retaining the object between requests. Healthy retention Age climbs between samples 20 170 six samples, thirty seconds apart Edge evicting early Age resets on most samples 12 38 same interval, nothing retained A sawtooth means eviction, a rotating node, or a cache-eligibility rule refusing to store.
The slope is the diagnosis: a straight climb means one node holds one copy; a sawtooth means the object never settles.

Is This the CDN’s Fault or Mine?

Work the checks in this order and stop at the first one that fires.

  1. 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.
  2. Age: 0 with 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.
  3. Age above 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.
  4. Age larger than your own max-age. Either the edge is deliberately serving stale under stale-while-revalidate, or your Cache-Control never reached the edge in the first place. Compare the Cache-Control on the wire with the one in your config before blaming the cache.
  5. Age resets to zero on every sample with a miss. The edge is not retaining the object. Check for a Vary on a high-cardinality header, cookies on the asset response, or a cache rule marking the path ineligible.
  6. curl shows 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.