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.

Ordered bisection ladder Four gates in sequence: is it stored at all, is the key reused, is Vary narrow, and is the TTL longer than the gap between requests. Failing any gate names a specific fix and stops the investigation there. 1. Stored at all? age header present 2. Key reused? cardinality ratio 3. Vary narrow? variants per key 4. TTL > gap? arrival interval no-store, private or Set-Cookie on the response Fragmented keys strip tracking params sort the rest Vary too wide drop User-Agent bucket the rest TTL too short raise it, add stale-while-revalidate Stop at the first gate that fails. Fix it. Re-run from gate 1. Two fixes at once means neither is attributable.
Each gate is only meaningful once the previous one passes, which is why the order is not negotiable.

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.

Fragmentation before and after normalization The same one hundred thousand requests are shown twice. Under raw URLs they spread across forty thousand distinct keys, averaging two and a half requests per key. After stripping tracking parameters and sorting the remainder they collapse to twelve hundred keys, averaging eighty three requests per key. 100,000 requests, one route Raw URLs 40,000 distinct keys — 2.5 requests each — hit ratio 34% Normalized 1,200 distinct keys — 83 requests each — hit ratio 93% Fragmentation factor = 40,000 / 1,200 = 33x The same bytes, the same TTL, the same origin — only the key changed.
Nothing about the content, the TTL or the origin changed here; thirty-three copies of every entry simply stopped being created.

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.

Two-request probe sequence A client sends the same logical request twice. The edge derives a key, checks the cache, and forwards to origin on the first request. On the second request a matching key produces a hit with a non-zero Age, while a mismatched key produces another origin fetch. Client Edge PoP Cache store Origin GET /p?utm_source=a lookup key /p miss, fetch 200, stored, no Age header GET /p?utm_source=b lookup key /p hit 200, Age: 47
The whole of step 1 is this diagram: if the second response has no Age, the first was never stored and the rest of the ladder is unreachable.

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.