Debugging Low Cache Hit Ratios at the Edge
This guide is part of Edge Caching & CDN Integration. It gives you an ordered bisection that takes a route with a bad hit ratio and, in four checks, tells you which of the possible causes is actually yours — before you change a single header.
The problem
A route sits at 34% hit ratio. Someone raises the TTL from five minutes to an hour. Nothing changes. Someone else removes Vary: Accept-Language. Nothing changes. A third person adds a cache rule in the dashboard, the ratio jumps to 71%, and nobody can explain why, so nobody can tell whether it will hold.
That sequence is not bad luck; it is the predictable result of guessing. Caching failures have several distinct causes that produce the same headline symptom, and each cause is invisible to the fix for every other cause. Raising a TTL on a response the CDN never stored does exactly nothing, and it does nothing silently — no error, no warning, just an unchanged number a day later.
Root cause: the cache is unobservable, so causes must be excluded in order
You cannot ask a CDN what is in its cache. There is no key enumeration API, no per-key hit counter, and no cross-PoP view. Every PoP holds its own LRU-evicted subset, and the only thing you can observe is one status header per response, after the fact.
Because you cannot look inside, you must exclude causes from the outside, in an order where each check is only meaningful once the previous one has passed. Cacheability comes first because if the response is never stored, cardinality, Vary and TTL are all irrelevant. Cardinality comes second because if each key is only ever requested once, no TTL can produce a hit. Vary comes third because it is cardinality hiding in headers instead of the URL. TTL comes last because it is the only check whose answer depends on the arrival rate of real traffic, which means it is the only one you cannot answer from a single request.
Run them out of order and you will draw a false conclusion at least half the time.
Step 1 — Confirm the response is cacheable at all
Before anything else, establish that the CDN is willing to store this response. Two requests, seconds apart, to the same URL: if the second one does not come back with a non-zero Age, nothing was stored, and every other investigation is wasted effort.
This check is worth automating because the reason for refusal is almost always visible in the first response’s own headers. The following runs anywhere with fetch and reports the specific blocker.
// tools/cacheability.ts
export interface CacheabilityReport {
url: string;
storable: boolean;
blockers: string[];
status: string | null;
age: number | null;
}
const BLOCKING_DIRECTIVES = ['no-store', 'private', 'no-cache'];
export async function checkCacheability(url: string): Promise<CacheabilityReport> {
const first = await fetch(url, { method: 'GET', headers: { 'cache-control': 'no-cache' } });
await first.arrayBuffer();
const blockers: string[] = [];
const cc = (first.headers.get('cache-control') ?? '').toLowerCase();
for (const d of BLOCKING_DIRECTIVES) {
if (cc.includes(d)) blockers.push(`cache-control: ${d}`);
}
if (/(^|,)\s*(s-)?maxage=0/.test(cc.replace(/-/g, ''))) blockers.push('max-age=0');
if (cc === '') blockers.push('no cache-control at all (heuristic caching only)');
if (first.headers.has('set-cookie')) blockers.push('set-cookie on the response');
if (first.headers.get('vary') === '*') blockers.push('vary: *');
if (first.status !== 200) blockers.push(`status ${first.status}`);
// Second request decides it empirically: a non-zero Age proves storage.
const second = await fetch(url);
await second.arrayBuffer();
const ageRaw = second.headers.get('age');
const age = ageRaw ? Number.parseInt(ageRaw, 10) : null;
return {
url,
storable: age !== null && age >= 0 && blockers.length === 0,
blockers,
status:
second.headers.get('cf-cache-status') ??
second.headers.get('x-vercel-cache') ??
second.headers.get('cache-status'),
age,
};
}
If blockers is non-empty, stop here. The most common entry is set-cookie on the response, which comes from a framework touching the session on a page that did not need it, and the second most common is a bare cache-control: private emitted by a default middleware. Neither is fixed by TTL tuning. If blockers is empty but age is still null, the CDN is applying a rule you did not write — check the dashboard rules and any Cache-Control overrides at the CDN layer.
Step 2 — Instrument the route with a key-and-status logger
Once the response is storable, you need a population, not a probe. Deploy a middleware that emits one line per request tying together the raw URL, the key you think is being used, and the status you actually got. This is the data every remaining step consumes.
// middleware.ts
const DROP_PARAMS = new Set([
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
'gclid', 'fbclid', 'msclkid', 'ref', '_',
]);
const VARY_INPUTS = ['accept-encoding', 'accept-language', 'user-agent', 'cookie'];
function normalizeKey(rawUrl: string): string {
const url = new URL(rawUrl);
const kept: Array<[string, string]> = [];
for (const [k, v] of url.searchParams) {
if (!DROP_PARAMS.has(k.toLowerCase())) kept.push([k.toLowerCase(), v]);
}
kept.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1));
const path = url.pathname.replace(/\/+$/, '') || '/';
const qs = kept.map(([k, v]) => `${k}=${v}`).join('&');
return qs ? `${path}?${qs}` : path;
}
function varyFingerprint(request: Request, varyHeader: string | null): string {
if (!varyHeader) return '-';
const names = varyHeader.toLowerCase().split(',').map((n) => n.trim());
return names
.filter((n) => VARY_INPUTS.includes(n))
.map((n) => `${n}=${(request.headers.get(n) ?? '').slice(0, 64)}`)
.join('|');
}
export default async function middleware(request: Request): Promise<Response> {
const response = await fetch(request);
const url = new URL(request.url);
console.log(JSON.stringify({
event: 'cache.probe',
path: url.pathname,
rawKey: url.pathname + url.search,
normKey: normalizeKey(request.url),
vary: response.headers.get('vary'),
varyFp: varyFingerprint(request, response.headers.get('vary')),
status:
response.headers.get('cf-cache-status') ??
response.headers.get('x-vercel-cache') ??
response.headers.get('cache-status') ??
'unknown',
age: response.headers.get('age'),
ts: Date.now(),
}));
return response;
}
Two fields do the heavy lifting. rawKey and normKey differ exactly when tracking parameters or parameter ordering are fragmenting your cache, and their distinct-count ratio quantifies it. varyFp reconstructs the variant dimension the CDN is keying on, so you can count variants per key without guessing.
Scope the matcher to the route under investigation. Sampling everything triples your log volume for no diagnostic gain, and static assets will drown the signal.
export const config = {
runtime: 'edge',
matcher: ['/product/:path*', '/api/search'],
};
Step 3 — Compute cardinality from the sample
Cardinality is the number that decides between “fix the keys” and “fix the TTL”, and you can compute it from a few thousand log lines. Three quantities matter: distinct raw keys, distinct normalized keys, and total requests.
// tools/cardinality.ts
export interface Probe {
rawKey: string;
normKey: string;
varyFp: string;
status: string;
ts: number;
}
export interface Cardinality {
requests: number;
distinctRaw: number;
distinctNorm: number;
fragmentationFactor: number; // distinctRaw / distinctNorm
requestsPerKey: number; // requests / distinctNorm
variantsPerKey: number; // distinct (normKey, varyFp) / distinctNorm
medianGapSeconds: number | null;
}
export function analyze(probes: Probe[]): Cardinality {
const raw = new Set<string>();
const norm = new Set<string>();
const variants = new Set<string>();
const byKey = new Map<string, number[]>();
for (const p of probes) {
raw.add(p.rawKey);
norm.add(p.normKey);
variants.add(`${p.normKey}##${p.varyFp}`);
const list = byKey.get(p.normKey);
if (list) list.push(p.ts); else byKey.set(p.normKey, [p.ts]);
}
// Median inter-arrival gap across keys that were requested more than once.
const gaps: number[] = [];
for (const times of byKey.values()) {
if (times.length < 2) continue;
times.sort((a, b) => a - b);
for (let i = 1; i < times.length; i++) gaps.push((times[i] - times[i - 1]) / 1000);
}
gaps.sort((a, b) => a - b);
const medianGapSeconds = gaps.length ? gaps[Math.floor(gaps.length / 2)] : null;
const distinctNorm = norm.size || 1;
return {
requests: probes.length,
distinctRaw: raw.size,
distinctNorm: norm.size,
fragmentationFactor: raw.size / distinctNorm,
requestsPerKey: probes.length / distinctNorm,
variantsPerKey: variants.size / distinctNorm,
medianGapSeconds,
};
}
Read the output like this. A fragmentationFactor near 1.0 means your URLs are already clean and normalization will buy you nothing. Above about 3, tracking parameters are your primary problem and stripping them is the single highest-value change available. A requestsPerKey below 2 means most keys are requested exactly once in the window, which is the sparse-tail signature — no configuration change will help, and the answer is tiered caching or a faster origin.
Step 4 — Check Vary width, then TTL against the arrival gap
variantsPerKey from the previous step is the Vary verdict. A value at or just above 1.0 is healthy — you are almost certainly varying only on Accept-Encoding, which yields two or three compression variants that all get used. A value in the hundreds means a header with unbounded cardinality is in your Vary list, and it is User-Agent or Cookie roughly every time. The fix is to stop varying on the raw header and vary on a small derived bucket instead, which varying edge cache by cookie covers in depth.
Only when cacheability, cardinality and Vary all pass does TTL become the answer. The test is arithmetic: compare medianGapSeconds against the effective TTL from s-maxage or max-age. If the median gap between two requests for the same key is 400 seconds and your TTL is 60, every request is a fresh fetch and the hit ratio is bounded near zero by construction. Raise the TTL well above the gap — a factor of ten is a reasonable starting point — and use stale-while-revalidate so a long TTL does not mean stale content.
export type Verdict =
| { cause: 'uncacheable'; detail: string }
| { cause: 'fragmented-keys'; factor: number }
| { cause: 'wide-vary'; variantsPerKey: number }
| { cause: 'short-ttl'; gapSeconds: number; ttlSeconds: number }
| { cause: 'sparse-tail'; requestsPerKey: number }
| { cause: 'healthy' };
export function verdict(
report: { storable: boolean; blockers: string[] },
c: { fragmentationFactor: number; variantsPerKey: number; requestsPerKey: number; medianGapSeconds: number | null },
ttlSeconds: number,
): Verdict {
if (!report.storable) return { cause: 'uncacheable', detail: report.blockers[0] ?? 'not stored' };
if (c.fragmentationFactor >= 3) return { cause: 'fragmented-keys', factor: c.fragmentationFactor };
if (c.variantsPerKey >= 3) return { cause: 'wide-vary', variantsPerKey: c.variantsPerKey };
if (c.medianGapSeconds !== null && c.medianGapSeconds > ttlSeconds) {
return { cause: 'short-ttl', gapSeconds: c.medianGapSeconds, ttlSeconds };
}
if (c.requestsPerKey < 2) return { cause: 'sparse-tail', requestsPerKey: c.requestsPerKey };
return { cause: 'healthy' };
}
The ordering inside verdict is the bisection, encoded. Keep it in that order even though it reads like a series of independent guards — an uncacheable response will also look fragmented, and a fragmented key set will also look sparse, so the earlier causes must shadow the later ones.
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
| CDN cache | Absent — wrangler dev and next dev do not run a shared cache |
Present, per-PoP, LRU-evicted |
| Cache status header | Missing or always DYNAMIC |
The real value, and the only reliable signal |
Age header |
Never set | Set on every stored response |
| Key cardinality | One user, a handful of keys | Real traffic, real tracking parameters |
Vary impact |
Invisible with one browser | Multiplied by every distinct client |
| Inter-arrival time | Your own click rate | Actual production arrival rate, the only one that matters |
| Compression variants | Often disabled | br and gzip both stored |
The consequence is blunt: hit ratio cannot be debugged locally. Local emulation, as covered in local emulation with wrangler dev vs production, verifies that your key derivation and logging are correct; only production traffic tells you what the keys look like. Test the pure functions locally, gather the population in production.
Validating with Vitest
The parts worth testing are the pure ones: key normalization must be stable and order-insensitive, and the cardinality analysis must produce the numbers you think it does. Both are trivially testable without a network.
// cache-debug.test.ts
import { describe, expect, it } from 'vitest';
import { analyze } from './tools/cardinality';
import { normalizeKey } from './middleware';
const probe = (rawKey: string, normKey: string, varyFp: string, ts: number) =>
({ rawKey, normKey, varyFp, status: 'MISS', ts });
describe('normalizeKey', () => {
it('produces the same key regardless of parameter order', () => {
expect(normalizeKey('https://x.test/p?b=2&a=1'))
.toBe(normalizeKey('https://x.test/p?a=1&b=2'));
});
it('drops tracking parameters that do not affect the response', () => {
expect(normalizeKey('https://x.test/p?utm_source=news&fbclid=abc'))
.toBe('/p');
});
it('keeps parameters that do affect the response', () => {
expect(normalizeKey('https://x.test/p?page=2&utm_medium=email'))
.toBe('/p?page=2');
});
it('treats a trailing slash as the same resource', () => {
expect(normalizeKey('https://x.test/p/')).toBe(normalizeKey('https://x.test/p'));
});
});
describe('analyze', () => {
it('reports the fragmentation factor from raw versus normalized keys', () => {
const probes = [
probe('/p?utm_source=a', '/p', '-', 0),
probe('/p?utm_source=b', '/p', '-', 1_000),
probe('/p?utm_source=c', '/p', '-', 2_000),
probe('/p?utm_source=d', '/p', '-', 3_000),
];
const c = analyze(probes);
expect(c.distinctRaw).toBe(4);
expect(c.distinctNorm).toBe(1);
expect(c.fragmentationFactor).toBe(4);
});
it('counts variants per key when Vary splits one key', () => {
const probes = [
probe('/p', '/p', 'user-agent=chrome', 0),
probe('/p', '/p', 'user-agent=firefox', 1_000),
probe('/p', '/p', 'user-agent=safari', 2_000),
];
expect(analyze(probes).variantsPerKey).toBe(3);
});
it('computes the median inter-arrival gap in seconds', () => {
const probes = [
probe('/p', '/p', '-', 0),
probe('/p', '/p', '-', 10_000),
probe('/p', '/p', '-', 30_000),
];
expect(analyze(probes).medianGapSeconds).toBe(20);
});
});
The order-insensitivity test is the one that catches real regressions. Sorting is easy to lose during a refactor, and losing it silently doubles or triples your key count for any URL with more than one parameter — a change nobody notices until the ratio drifts down a week later.
Common pitfalls and resolutions
Raising the TTL changed nothing — the response was never stored. Run step 1 first; a no-store, private, or Set-Cookie header makes TTL irrelevant.
The ratio improved after a dashboard rule but nobody knows why — the rule almost certainly stripped query parameters or forced a cache level, doing step 3’s job invisibly. Reproduce it in your own normalization so it is versioned and testable.
Cardinality looks fine but the ratio is still low — you measured over too short a window. Keys requested once an hour look unique in a five-minute sample. Collect at least ten times your TTL.
Normalization broke pagination — a parameter that does affect the response was in the drop list. Drop lists must be explicit allow-by-default; never drop by pattern.
Vary looks narrow in the header but variants are high — the CDN may be adding its own dimension, such as device class or country. Check provider-specific cache key settings, not just the Vary header.
Hit ratio is fine everywhere except one region — a small PoP is evicting your working set under LRU pressure. That is a capacity problem, not a header problem; enable tiered caching.
The logger itself made things slower — a per-request fetch to an analytics endpoint. Use console.log and a log drain, and sample if volume is high.
Production deployment checklist
Frequently Asked Questions
Why does raising the TTL so often change nothing?
Because the response was never stored in the first place. A no-store or private directive, a Set-Cookie header, or a status other than 200 makes the CDN refuse to keep the response, and a longer TTL on something that is never kept has no effect at all. Confirm storage empirically with a non-zero Age header on a second identical request before touching any TTL.
How large a sample do I need to compute cardinality?
A few thousand requests is usually enough to distinguish fragmentation from a sparse tail, but the window matters more than the count. Collect over a period at least ten times your configured TTL, because keys requested less often than that will look unique in a short sample and will make healthy traffic resemble a sparse tail.
What fragmentation factor is worth acting on?
A factor near one means your URLs are already clean and normalization will buy you nothing. Around three, tracking parameters are meaningfully diluting your cache. Above ten, key normalization is almost certainly the single highest-value change available to you, and it will usually move the ratio more than every other fix combined.
Can I debug hit ratio in local development?
No. Local emulators do not run a shared CDN cache, never set an Age header, and see traffic from exactly one client, so key cardinality, Vary impact and inter-arrival time are all unobservable. Use local tests to prove your key derivation is correct and deterministic, then gather the actual population from production traffic.
Should I count STALE responses as hits while debugging?
Yes, because a stale response was served from the edge without the user waiting on origin. If you classify it as a miss, enabling stale-while-revalidate will appear to make your cache worse at the exact moment it makes users faster. Keep a separate stale series so you can still see how often revalidation is running behind.
Why does the verdict function check causes in a fixed order?
Because the causes are not independent in their symptoms. An uncacheable response also looks fragmented, and a fragmented key set also looks like a sparse tail, so an earlier cause must shadow every later one. Checking in any other order will confidently report the wrong answer roughly half the time.