Reading Cache Status Headers at the Edge
This guide is part of Edge Caching & CDN Integration. It does one thing: turns the several incompatible cache status headers the major CDNs emit into a single internal enum you can group a dashboard by, with parsing code and a test suite that pins the mapping down.
The problem
You write a dashboard query that counts cf-cache-status = 'HIT'. It works. Then a route moves behind Vercel and the series flatlines, because Vercel spells it x-vercel-cache. You add a second branch. Then someone enables stale-while-revalidate and the hit ratio appears to fall, because STALE is not HIT in your query even though the user got their bytes from the edge. Then a Netlify function starts returning Cache-Status: "Netlify Edge"; hit — a structured field with quoted names and semicolon-separated parameters — and your string comparison matches nothing at all.
Six months later the query is forty lines of CASE WHEN, two of the branches are wrong, and nobody trusts the number.
Root cause: three header conventions and no shared vocabulary
The status header is not standardized in the way you would expect from something this universal. There are three conventions in play, and they disagree about syntax, vocabulary and even about what a “hit” is.
The proprietary single-token headers — cf-cache-status, x-vercel-cache, x-cache — carry one bare word describing one cache’s decision. They are easy to parse and impossible to extend: they cannot express what happened at a second tier, they cannot carry the TTL or the key, and each vendor’s vocabulary is its own.
RFC 9211’s Cache-Status is the structured alternative. Each cache in the chain appends a member: a name identifying that cache, followed by parameters such as hit, fwd=miss, ttl=, key=. Because it is a list, one header can tell you the edge missed and the shield hit, which no single-token header can express. It is also a Structured Field, so it has real parsing rules rather than string equality.
The third convention is the one people forget: Age. It is standardized, it is emitted by every CDN, and it is the only header that answers “was this actually stored” without trusting any vendor vocabulary. An Age of 47 means this response has been sitting in a shared cache for 47 seconds, whatever anyone’s status token claims.
Normalizing is not cosmetic. It is what lets a dashboard survive a CDN migration, and it is what forces you to decide once, explicitly, whether STALE and PRERENDER count as hits.
Step 1 — Define the internal enum and decide what a hit is
Everything downstream depends on this file, so make the semantic choices explicit here rather than scattering them through query logic.
// lib/cache-status.ts
/** One vocabulary for every provider. Never store a raw vendor token. */
export type CacheOutcome =
| 'hit' // served from cache, still fresh
| 'stale' // served from cache past freshness, revalidating behind
| 'revalidated' // stored copy confirmed fresh by a conditional request
| 'expired' // was stored, freshness elapsed, refetched
| 'miss' // cacheable, not present, fetched and stored
| 'bypass' // eligible in principle, a rule skipped the cache
| 'dynamic' // the response declared itself uncacheable
| 'unknown'; // no recognisable status header
export interface CacheStatus {
outcome: CacheOutcome;
/** Seconds since the response was stored, from the Age header. */
age: number | null;
/** Remaining freshness in seconds, when the provider reports it. */
ttl: number | null;
/** Name of the cache node that answered, when reported. */
node: string | null;
/** The raw token, kept only for spot-checking odd rows. */
raw: string | null;
}
/**
* A "hit" is any response the edge served without the user waiting on origin.
* stale and revalidated qualify; expired does not, because the user waited.
*/
export function servedFromEdge(o: CacheOutcome): boolean {
return o === 'hit' || o === 'stale' || o === 'revalidated';
}
/** Responses that were never eligible do not belong in the denominator. */
export function countsTowardRatio(o: CacheOutcome): boolean {
return o !== 'dynamic' && o !== 'unknown';
}
Two decisions are encoded here and both are contentious enough to be worth stating in a comment. stale counts as a hit because the bytes came from the edge; classifying it as a miss makes enabling stale-while-revalidate look like a regression. revalidated counts as a hit for byte savings — the origin returned a 304 and no body crossed the wire — even though the user did pay a conditional round trip. If your dashboard is about latency rather than egress, move revalidated to the other side and say so in the same comment.
Step 2 — Parse each provider’s vocabulary
Three parsers, one output shape. Take them in order of specificity: the structured header first, because it is the richest, then the vendor tokens.
// lib/cache-status.ts (continued)
const CF_MAP: Record<string, CacheOutcome> = {
HIT: 'hit',
MISS: 'miss',
EXPIRED: 'expired',
STALE: 'stale',
UPDATING: 'stale', // stale served while a refresh is in flight
REVALIDATED: 'revalidated',
BYPASS: 'bypass',
DYNAMIC: 'dynamic',
NONE: 'dynamic',
};
const VERCEL_MAP: Record<string, CacheOutcome> = {
HIT: 'hit',
MISS: 'miss',
STALE: 'stale',
PRERENDER: 'hit', // statically generated, served from the edge
REVALIDATED: 'revalidated',
BYPASS: 'bypass',
};
/** RFC 9211: a list of members, each `"name"; param; param=value`. */
export function parseCacheStatusField(value: string): CacheStatus | null {
// Split on commas that are not inside a quoted string.
const members: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < value.length; i++) {
const c = value[i];
if (c === '"') depth ^= 1;
else if (c === ',' && depth === 0) {
members.push(value.slice(start, i));
start = i + 1;
}
}
members.push(value.slice(start));
if (members.length === 0) return null;
// The member nearest the client is first; that is the edge decision.
const parts = members[0].split(';').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return null;
const node = parts[0].replace(/^"|"$/g, '') || null;
let outcome: CacheOutcome = 'unknown';
let ttl: number | null = null;
for (const p of parts.slice(1)) {
const [rawKey, rawVal] = p.split('=');
const key = rawKey.trim().toLowerCase();
const val = rawVal?.trim().replace(/^"|"$/g, '');
if (key === 'hit') outcome = 'hit';
else if (key === 'ttl') ttl = Number.parseInt(val ?? '', 10);
else if (key === 'fwd') {
if (val === 'stale') outcome = 'stale';
else if (val === 'bypass') outcome = 'bypass';
else if (val === 'uri-miss' || val === 'vary-miss' || val === 'miss') outcome = 'miss';
else if (val === 'method' || val === 'request') outcome = 'dynamic';
else outcome = 'miss';
}
}
// `collapsed` and `stored` refine a forward but do not change the outcome.
return { outcome, age: null, ttl: Number.isNaN(ttl) ? null : ttl, node, raw: value };
}
export function readCacheStatus(headers: Headers): CacheStatus {
const ageRaw = headers.get('age');
const parsedAge = ageRaw !== null ? Number.parseInt(ageRaw, 10) : NaN;
const age = Number.isNaN(parsedAge) ? null : parsedAge;
const std = headers.get('cache-status');
if (std) {
const parsed = parseCacheStatusField(std);
if (parsed) return { ...parsed, age };
}
const cf = headers.get('cf-cache-status');
if (cf) {
return {
outcome: CF_MAP[cf.toUpperCase()] ?? 'unknown',
age, ttl: null, node: headers.get('cf-ray')?.split('-')[1] ?? null, raw: cf,
};
}
const vercel = headers.get('x-vercel-cache');
if (vercel) {
return {
outcome: VERCEL_MAP[vercel.toUpperCase()] ?? 'unknown',
age, ttl: null, node: headers.get('x-vercel-id')?.split('::')[0] ?? null, raw: vercel,
};
}
// Last resort: Age alone proves the response came from a shared cache.
if (age !== null && age > 0) {
return { outcome: 'hit', age, ttl: null, node: null, raw: `age=${age}` };
}
return { outcome: 'unknown', age, ttl: null, node: null, raw: null };
}
The Age-only fallback at the bottom matters more than it looks. It is what keeps your data usable behind a corporate proxy, an unfamiliar CDN, or a provider that quietly renames a header — Age is standardized and everyone emits it, so it is the one signal that never needs a vendor mapping.
Note what parseCacheStatusField deliberately ignores. RFC 9211 defines collapsed, stored, detail and fwd-status, all of which refine a forward without changing whether the user got their bytes from the edge. Parsing them into the outcome would create categories nobody can chart; keep them in raw if you want them for spot checks.
Step 3 — Emit the normalized sample from middleware
With parsing isolated in a pure function, the middleware is four lines of glue. It must stay on the response path and must not perform network calls.
// middleware.ts
import { readCacheStatus, servedFromEdge, countsTowardRatio } from './lib/cache-status';
export default async function middleware(request: Request): Promise<Response> {
const response = await fetch(request);
const status = readCacheStatus(response.headers);
const url = new URL(request.url);
console.log(JSON.stringify({
event: 'cache.status',
path: url.pathname,
outcome: status.outcome,
hit: servedFromEdge(status.outcome),
counted: countsTowardRatio(status.outcome),
age: status.age,
ttl: status.ttl,
node: status.node,
raw: status.raw,
bytes: Number.parseInt(response.headers.get('content-length') ?? '0', 10) || 0,
}));
// Surface the normalized value to the browser for manual spot checks.
const out = new Response(response.body, response);
out.headers.set('x-cache-outcome', status.outcome);
return out;
}
Setting x-cache-outcome on the way out is worth the two bytes. It means anyone can open devtools, or run one curl, and see the same value your dashboard is grouping by — which removes an entire class of “the dashboard says HIT but I got a slow response” arguments.
curl -sSI 'https://example.com/product/123' \
| grep -iE 'x-cache-outcome|cf-cache-status|x-vercel-cache|cache-status|^age:'
Step 4 — Scope the matcher
Immutable build assets hit essentially always and will swamp any aggregate they are included in. Exclude them, along with anything that was never going to be cached.
export const config = {
runtime: 'edge',
matcher: [
'/((?!_next/static|_next/image|favicon.ico|robots.txt|api/auth|account|checkout).*)',
],
};
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
cf-cache-status |
Absent under wrangler dev |
Present on every proxied response |
x-vercel-cache |
Absent under next dev |
Present, and PRERENDER appears after a build |
cache-status |
Absent | Present on Netlify and Fastly; increasingly elsewhere |
Age |
Never set | Set on every stored response |
| Parser exercise | Only via fixtures in tests | Every vocabulary, including tokens you did not map |
unknown rate |
100 percent | Should be under 1 percent; anything more is a mapping gap |
| Multi-tier members | Never | Two or more members once a shield is enabled |
The practical consequence is that the parser must be tested against fixture strings, not against a running local server. Local development will hand you unknown for every request, which is correct behaviour and tells you nothing. What production will tell you is your unknown rate — and that number is a live alarm on vocabulary drift, because a provider adding a token you have not mapped shows up there first.
Validating with Vitest
The suite is pure string handling, so it runs in milliseconds and covers every vocabulary at once.
// cache-status.test.ts
import { describe, expect, it } from 'vitest';
import { readCacheStatus, parseCacheStatusField, servedFromEdge } from './lib/cache-status';
const h = (init: Record<string, string>) => new Headers(init);
describe('readCacheStatus — vendor tokens', () => {
it('maps a Cloudflare hit and reads Age', () => {
const s = readCacheStatus(h({ 'cf-cache-status': 'HIT', age: '47' }));
expect(s.outcome).toBe('hit');
expect(s.age).toBe(47);
});
it('maps UPDATING to stale, not to a separate category', () => {
expect(readCacheStatus(h({ 'cf-cache-status': 'UPDATING' })).outcome).toBe('stale');
});
it('separates DYNAMIC from BYPASS', () => {
expect(readCacheStatus(h({ 'cf-cache-status': 'DYNAMIC' })).outcome).toBe('dynamic');
expect(readCacheStatus(h({ 'cf-cache-status': 'BYPASS' })).outcome).toBe('bypass');
});
it('treats a Vercel PRERENDER as a hit', () => {
expect(readCacheStatus(h({ 'x-vercel-cache': 'PRERENDER' })).outcome).toBe('hit');
});
it('is case-insensitive about the token', () => {
expect(readCacheStatus(h({ 'x-vercel-cache': 'stale' })).outcome).toBe('stale');
});
it('returns unknown for a token it has never seen', () => {
expect(readCacheStatus(h({ 'cf-cache-status': 'QUANTUM' })).outcome).toBe('unknown');
});
});
describe('parseCacheStatusField — RFC 9211', () => {
it('reads a hit with a remaining ttl and a node name', () => {
const s = parseCacheStatusField('"ExampleCache"; hit; ttl=284');
expect(s).toMatchObject({ outcome: 'hit', ttl: 284, node: 'ExampleCache' });
});
it('reads a forward as a miss', () => {
expect(parseCacheStatusField('"edge-lhr"; fwd=uri-miss')?.outcome).toBe('miss');
});
it('takes the member nearest the client when caches are chained', () => {
const v = '"edge-lhr"; fwd=miss, "shield-fra"; hit; ttl=99';
expect(parseCacheStatusField(v)?.outcome).toBe('miss');
expect(parseCacheStatusField(v)?.node).toBe('edge-lhr');
});
it('does not split on a comma inside a quoted key parameter', () => {
const v = '"edge"; hit; key="/p?tags=a,b,c"';
expect(parseCacheStatusField(v)?.outcome).toBe('hit');
expect(parseCacheStatusField(v)?.node).toBe('edge');
});
});
describe('fallbacks and classification', () => {
it('prefers the standard field over a vendor token', () => {
const s = readCacheStatus(h({ 'cache-status': '"n"; hit', 'cf-cache-status': 'MISS' }));
expect(s.outcome).toBe('hit');
});
it('infers a hit from a positive Age when no status header exists', () => {
expect(readCacheStatus(h({ age: '12' })).outcome).toBe('hit');
});
it('returns unknown when nothing identifies the cache', () => {
expect(readCacheStatus(h({})).outcome).toBe('unknown');
});
it('counts stale and revalidated as served from the edge', () => {
expect(servedFromEdge('stale')).toBe(true);
expect(servedFromEdge('revalidated')).toBe(true);
expect(servedFromEdge('expired')).toBe(false);
});
});
The quoted-comma test is the one that catches the bug everyone writes first. Splitting Cache-Status on a bare comma works perfectly until a cache includes the key as a parameter and that key contains a comma, at which point one member becomes two and the second one parses as garbage. Structured Fields are not CSV.
Common pitfalls and resolutions
Splitting Cache-Status on a bare comma — a quoted key parameter containing a comma breaks the split. Track quote depth, as the parser above does.
Treating DYNAMIC and BYPASS as the same thing — DYNAMIC means the response declared itself uncacheable, so the fix is in your application. BYPASS means a CDN rule skipped the cache, so the fix is in your dashboard configuration. Merging them hides which of the two you have.
Counting PRERENDER as a miss — it is a statically generated page served from the edge with no origin round trip. It is a hit for latency purposes; treating it otherwise makes static generation look useless.
Trusting the status header over Age — some proxies emit a status token from a tier you did not intend to measure. A positive Age is the standardized proof that a shared cache stored the response.
Ignoring the unknown bucket — it is your regression alarm. A provider adding a token, or renaming a header, appears there before it appears anywhere else. Alert on it crossing one percent.
Reading only the first Cache-Status member and calling it the answer — for edge hit ratio that is correct, but for origin offload you want to know whether any member hit. Keep the full list in raw so you can compute both later.
Mutating the response headers without cloning — response.headers.set on a response you did not construct throws in some runtimes. Build new Response(response.body, response) first, as above.
Production deployment checklist
Frequently Asked Questions
What is the difference between DYNAMIC and BYPASS on Cloudflare?
DYNAMIC means the response was never eligible for caching, usually because it declared no-store or private, or because the content type is not cached by default. BYPASS means the response was eligible but a rule told the cache to skip it. The distinction matters because the first is fixed in your application and the second is fixed in your CDN configuration.
Should PRERENDER count as a cache hit?
Yes, for latency and for origin offload. A PRERENDER response is a statically generated page served from the edge without any origin round trip, which is exactly the outcome hit ratio is meant to reward. If you classify it as a miss, static generation will appear to do nothing for your numbers.
Why prefer the Cache-Status header over the vendor headers?
Because it is a structured field that every cache in the chain appends to, so one header can tell you that the edge forwarded and the shield hit. It also carries the remaining time to live and sometimes the cache key itself, which removes most of the guesswork from cardinality analysis. Vendor tokens can express only one cache’s decision and cannot be extended.
Can I rely on Age alone if a provider has no status header?
Largely, yes. Age is standardized and emitted by every conforming shared cache, and a positive value proves the response was stored and reused. What it cannot tell you is why a miss happened, so treat it as a reliable floor rather than a full replacement for the status token.
Why not just split the Cache-Status value on commas?
Because parameters may be quoted strings that contain commas, most often the cache key when the key includes a comma separated list. A naive split turns one list member into two and the second parses as nonsense. Track quote depth while scanning, which is a few lines and removes the whole class of bug.
What should I do about statuses my mapping does not recognise?
Route them to an explicit unknown outcome, keep the raw token, and alert when unknown exceeds about one percent of requests. That bucket is your early warning for a provider adding a token or renaming a header, and it will surface a vocabulary change days before anyone notices a dashboard looking wrong.