Cache Hit Ratio Analysis and Debugging at the Edge

This guide is part of Edge Caching & CDN Integration. It explains how to measure edge cache hit ratio in a way that survives scrutiny, how to attribute a low ratio to one of five structural causes, and how to build a per-route dashboard out of the cache status headers your CDN already returns.

Hit ratio is the most quoted and least understood number in edge caching. A dashboard says 94% and everyone relaxes, but origin egress is still climbing and p99 latency is still ugly. Or a dashboard says 61% and a team spends a sprint tuning TTLs when the actual problem is that a tracking query parameter has fragmented one URL into two hundred thousand cache keys. Both failures come from the same place: the number is an aggregate over a population you have not defined, and aggregates hide structure.

The useful discipline is to stop treating hit ratio as a score and start treating it as a ratio with a denominator you chose. Once you can say precisely which requests are in the denominator, which tier you measured at, and whether you counted requests or bytes, the number starts pointing at causes instead of moods.

What “hit ratio” actually means

The naive definition is hits divided by total requests. That is fine as far as it goes, but three ambiguities make two teams report wildly different numbers for the same traffic.

By request or by byte. Request hit ratio counts each response equally: a 400-byte JSON payload and a 4 MB video segment are one request each. Byte hit ratio weights by response size, which is what actually determines origin egress cost and origin bandwidth saturation. A site serving many small API responses from cache and a handful of huge uncached downloads can post a 96% request hit ratio and a 30% byte hit ratio simultaneously. Neither is wrong. They answer different questions: request ratio tracks user-perceived latency, byte ratio tracks money and origin load. Report both or state which one you mean.

Which tier. In a multi-tier CDN architecture a request that misses at the local PoP may still hit at the upper tier or origin shield, never touching your origin at all. If you measure at the edge you get the user-latency number; if you measure at origin you get the origin-protection number. A well-configured tiered cache routinely shows 78% edge hit and 97% effective offload, because the shield absorbs the edge misses. Reporting only the edge number makes a healthy system look broken.

What is in the denominator. Do uncacheable responses count? Authenticated dashboard pages, POST requests, and streaming endpoints will never be cached, and including them drags the ratio down permanently while telling you nothing actionable. The number that guides work is the ratio computed over the population you intended to cache. Track the total too, but as a separate series, and track the fraction of traffic that is intentionally uncacheable as a third — a rising uncacheable share is itself a signal.

Four ways to divide the same traffic The same one hundred thousand requests produce four different hit ratio figures depending on whether requests or bytes are counted and whether measurement happens at the edge PoP or after the origin shield. Each measurement answers a different operational question. Same 100k requests, four legitimate answers By request, edge 78% By byte, edge 42% By request, offload 97% Cacheable only 92% Latency lives in the first bar. Egress cost lives in the second. Origin capacity lives in the third. Your backlog lives in the fourth.
Quoting one bar without naming which one is how two teams argue for a week about a number they both measured correctly.

The runtime constraint that drives the pattern

You cannot query a CDN’s cache. There is no SELECT COUNT(DISTINCT key) against a PoP, no way to enumerate what is resident, and no cross-PoP view of which keys exist where. Every PoP holds an independent, LRU-evicted subset of your content, sized by that PoP’s traffic mix and hardware. This is the constraint that makes hit ratio debugging feel like archaeology: the only observable is the per-response status header, one request at a time.

That single observable is also enough, provided you capture it at the point where you still know the logical identity of the request. This is exactly what edge middleware is for. Middleware runs before or alongside the cache lookup, can see the raw URL, the cookies, the Vary-relevant headers and the resulting cache status, and can emit one structured log line per request that ties them together. Analytics dashboards from the provider give you the aggregate; your middleware gives you the join key.

The second constraint is cardinality. A cache is a fixed-size store under LRU pressure. Hit ratio is not primarily a function of TTL — it is a function of how many distinct keys share a given volume of traffic, and how often each key is requested relative to its TTL. If a key is requested once every ten minutes and its TTL is sixty seconds, it will never hit, no matter how correct your headers are. Cardinality and inter-arrival time are the physics; headers are just the control surface.

The five structural causes of a low ratio

Nearly every genuine hit-ratio problem reduces to one of five causes. They have distinct signatures, and distinguishing them before touching config is the whole job.

1. Fragmented cache keys. The same logical resource is stored under many keys because something varying is in the key. Query parameters are the usual culprit: utm_source, fbclid, gclid, session ids, cache-busting timestamps, and reordered parameter lists all mint fresh keys for byte-identical content. The signature is a very high count of distinct keys relative to distinct logical resources, and a MISS rate that is roughly constant regardless of TTL. The fix is key normalization, covered in normalizing query parameters in edge cache keys.

2. Over-broad Vary. Vary: Accept-Encoding is fine and necessary. Vary: User-Agent multiplies every entry by the number of distinct user agent strings in the wild, which is effectively unbounded. Vary: Cookie is worse, because every distinct cookie jar is a distinct variant, and analytics cookies make cookie jars unique per visitor. The signature is identical to fragmentation, but the cardinality lives in headers rather than the URL — same URL, many variants. See varying edge cache by cookie for the containment strategy.

3. TTLs shorter than the inter-arrival time. The entry expires before the next request for it arrives. This is the cause people most often assume and most often get wrong, because it only applies to genuinely low-traffic resources. For a resource requested a thousand times a minute, a sixty-second TTL still yields a 99.9% hit ratio. For a resource requested twice an hour, a sixty-second TTL yields zero. The signature is an EXPIRED-heavy status mix on low-volume routes, and the fix is longer TTLs plus stale-while-revalidate so freshness does not suffer.

4. Uncacheable directives. The response says Cache-Control: private, no-store, or max-age=0, or it carries a Set-Cookie header, and the CDN correctly refuses to store it. The signature is unmistakable and the cheapest to detect: the status is BYPASS or DYNAMIC rather than MISS, meaning the CDN never even attempted a lookup. Frameworks are the common source — a single server component reading cookies can flip an entire route to no-store.

5. Sparse tail traffic. Nothing is misconfigured. You simply have a catalogue of two million product pages, each requested a handful of times a day, spread over sixty PoPs, and no cache of any size can hold that working set. The signature is a hit ratio that is low in aggregate but decomposes into a healthy head and a hopeless tail. The fix is not header tuning; it is tiered caching to consolidate the tail at a shield, or accepting the ratio and optimizing origin response time instead.

Diagnosis decision tree Starting from a low hit ratio, three successive questions about status mix, key cardinality and traffic distribution route the investigation to fragmented keys, over-broad Vary, short TTLs, uncacheable directives, or sparse tail traffic. Each branch names the observable that selects it. Low hit ratio on one route BYPASS / DYNAMIC never stored MISS dominant key never reused EXPIRED dominant stored, aged out Mixed, head is fine tail is not Uncacheable directives no-store, private, Set-Cookie Fragmented keys cardinality in the URL Over-broad Vary cardinality in headers TTL below arrival gap raise TTL, add SWR Sparse tail traffic shield it, do not tune it Fix exactly one cause then re-measure
The status mix separates the causes before you touch a single header — MISS and EXPIRED mean completely different things and warrant opposite fixes.

Architecture overview: instrument, normalize, aggregate

A hit-ratio dashboard you can act on has three layers, and skipping any of them produces a number you cannot debug.

Instrument. Every response that leaves the edge emits one structured log line carrying the route pattern (not the raw path), the normalized cache key, the raw provider status header, and the response size. Route pattern matters more than anything else here: /product/[id] is a series you can reason about, /product/9f3a-2b is noise.

Normalize. Providers spell cache status differently and inconsistently. Map every provider value into one internal enum before it reaches storage, so a dashboard query does not need a per-provider CASE expression and a migration to a second CDN does not invalidate a year of history. The mechanics are in reading cache status headers at the edge.

Aggregate. Group by route pattern and status, over a rolling window, and compute both the request ratio and the byte ratio. Then, and only then, compute the site-wide number — as a derived rollup of the per-route series, never as a primary metric.

// lib/cache-observability.ts
export type CacheOutcome =
  | 'hit'        // served from cache, fresh
  | 'miss'       // cacheable, not present, fetched and stored
  | 'expired'    // was present, TTL elapsed, revalidated
  | 'stale'      // served stale while revalidating
  | 'bypass'     // cacheable in principle, skipped by rule
  | 'dynamic'    // response declared uncacheable
  | 'unknown';

export interface CacheSample {
  route: string;        // '/product/[id]', never the raw path
  key: string;          // normalized cache key
  outcome: CacheOutcome;
  bytes: number;
  ageSeconds: number | null;
  pop: string | null;
}

export function isHit(o: CacheOutcome): boolean {
  return o === 'hit' || o === 'stale';
}

export function isCacheable(o: CacheOutcome): boolean {
  return o !== 'dynamic' && o !== 'unknown';
}

Counting stale as a hit is a deliberate choice and worth stating explicitly in your definitions: a stale-while-revalidate response was served from the edge without the user waiting on origin, which is the thing hit ratio is supposed to measure. If you count it as a miss, enabling stale-while-revalidate will appear to make your cache worse while it makes users faster.

Core implementation: a per-route sampler

The sampler runs in middleware on the response path. It must be cheap — this code executes on every request, inside a CPU budget measured in milliseconds — so it does no allocation-heavy work and no network calls on the hot path.

// middleware/cache-sampler.ts
import type { CacheOutcome, CacheSample } from '../lib/cache-observability';

const ROUTE_PATTERNS: Array<[RegExp, string]> = [
  [/^\/product\/[^/]+$/, '/product/[id]'],
  [/^\/category\/[^/]+$/, '/category/[slug]'],
  [/^\/api\/search$/, '/api/search'],
  [/^\/blog\/[^/]+$/, '/blog/[slug]'],
];

export function routePattern(pathname: string): string {
  for (const [re, label] of ROUTE_PATTERNS) {
    if (re.test(pathname)) return label;
  }
  return pathname === '/' ? '/' : '/other';
}

// Keys that carry no server-side meaning and must not enter the cache key.
const DROP_PARAMS = new Set([
  'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
  'gclid', 'fbclid', 'msclkid', 'ref', 'mc_cid', '_',
]);

export 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] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1));
  const qs = kept.map(([k, v]) => `${k}=${v}`).join('&');
  const path = url.pathname.replace(/\/+$/, '') || '/';
  return qs ? `${url.host}${path}?${qs}` : `${url.host}${path}`;
}

export function sample(
  request: Request,
  response: Response,
  outcome: CacheOutcome,
): CacheSample {
  const url = new URL(request.url);
  const len = response.headers.get('content-length');
  const age = response.headers.get('age');
  return {
    route: routePattern(url.pathname),
    key: normalizeKey(request.url),
    outcome,
    bytes: len ? Number.parseInt(len, 10) || 0 : 0,
    ageSeconds: age ? Number.parseInt(age, 10) || 0 : null,
    pop: response.headers.get('cf-ray')?.split('-')[1] ?? null,
  };
}

Emitting the sample is a single console.log of JSON — structured logging is the only transport guaranteed on every edge platform, and log drains on all three providers can ship it to your warehouse. Do not fetch your analytics endpoint per request; that adds a network hop to every response and will dominate your CPU budget. If you need direct ingestion, batch it behind ctx.waitUntil with a sampling rate.

export function emit(s: CacheSample, sampleRate = 1): void {
  if (sampleRate < 1 && Math.random() >= sampleRate) return;
  console.log(JSON.stringify({ event: 'cache.sample', ...s }));
}

At high traffic, sample. A one-percent sample of ten million daily requests is a hundred thousand rows, which is more than enough to estimate a ratio to within a fraction of a percent, and it keeps your log bill from exceeding your CDN bill. Sample uniformly at random, never by taking the first N requests of a minute, or you will bias toward whatever traffic pattern is bursty.

Provider mapping

Provider Status header Values you will see Age exposure Notes
Cloudflare Workers cf-cache-status HIT, MISS, EXPIRED, STALE, REVALIDATED, UPDATING, BYPASS, DYNAMIC, NONE age on cached responses DYNAMIC means the response was never eligible; BYPASS means a rule skipped the cache. Distinguishing them is the fastest triage available.
Vercel Edge Middleware x-vercel-cache HIT, MISS, STALE, PRERENDER, REVALIDATED, BYPASS age on ISR responses PRERENDER is a first-request-after-build serve of a statically generated page — treat it as a hit for latency, a miss for freshness reasoning.
Netlify Edge Functions cache-status (RFC 9211) plus x-nf-request-id "Netlify Edge"; hit, "Netlify Edge"; fwd=miss, fwd=stale age and cache-status ttl parameter Netlify emits the standard structured header, which is self-describing and includes the cache node’s own name — the format other providers are converging on.
Fastly Compute cache-status plus x-cache hit, fwd=miss, fwd=uri-miss, ttl= and key= parameters age The richest signal: cache-status can carry the key and remaining TTL, which removes most of the guesswork from cardinality analysis.

The direction of travel is RFC 9211’s Cache-Status, a structured field where each cache in the chain appends its own entry. It is strictly better than the proprietary headers because it is self-describing, ordered, and multi-hop — a single header tells you the edge missed and the shield hit. Parse it when present and fall back to the vendor header when it is not.

Control-flow variants

Measure-only mode. The safest first deployment does nothing but observe: the sampler wraps the existing response, reads the status header, emits a log line, and returns the response untouched. No behaviour changes, no risk, and after a day you have the per-route baseline that every subsequent decision depends on. Resist the urge to fix anything in the same deploy.

Shadow normalization. Before you change the real cache key, log both the raw URL and the normalized key without applying the normalization. The ratio of distinct raw keys to distinct normalized keys is your fragmentation factor, and it predicts the hit-ratio improvement a normalization change will deliver. A factor of 1.1 means normalization is not your problem; a factor of 40 means it is your only problem.

Route-scoped rollout. Apply a fix to one route pattern first via the matcher, compare that route’s series against an unchanged control route over the same window, then widen. Site-wide changes to caching behaviour are hard to attribute because traffic mix shifts hourly.

Early exit on uncacheable routes. Sampling authenticated and POST traffic wastes CPU and pollutes the denominator. An early-return guard that skips the sampler for known-dynamic routes keeps both the cost and the data clean.

const NEVER_CACHED = /^\/(api\/auth|account|checkout|admin)(\/|$)/;

export function shouldSample(request: Request): boolean {
  if (request.method !== 'GET' && request.method !== 'HEAD') return false;
  return !NEVER_CACHED.test(new URL(request.url).pathname);
}
TTL versus request inter-arrival time Two timelines over the same one hundred and twenty seconds. The high traffic resource receives many requests inside each sixty second TTL window and hits almost every time. The low traffic resource receives one request every ninety seconds, so the entry always expires first and every request misses. Same 60 s TTL, opposite outcomes Hot route TTL window 1 TTL window 2 12 of 14 requests hit — 86% Cold route TTL expires unused TTL expires unused 0 of 2 requests hit — 0% Raising the cold route TTL to 30 minutes costs nothing and takes it to 90%+.
Hit ratio is set by the ratio of TTL to inter-arrival time, not by TTL alone — which is why one global TTL is always wrong for someone.

Framework integration notes

Next.js App Router. The cache surface is layered: the full route cache, the data cache, and the CDN in front of both. x-vercel-cache reports the outermost layer, so a HIT tells you nothing about whether the data cache was consulted. Instrument in middleware.ts and scope the matcher to exclude _next/static, which is immutable and will hit essentially always, inflating your aggregate. Watch for accidental opt-outs: any cookies(), headers(), or noStore() call in the render tree makes the route dynamic, which shows up in your data as a sudden shift from MISS to DYNAMIC on a route you thought was static.

Remix. Cache behaviour is entirely a function of the Cache-Control you set in loader responses, which makes it easy to instrument and easy to get wrong per-route. Add a small helper that every loader uses to set caching headers, and log the value it applied alongside the resulting cache status — the join between “what we asked for” and “what we got” is where misconfigurations become obvious.

SvelteKit. handle in src/hooks.server.ts sees both request and response, making it the natural sampler location. setHeaders in load functions controls cacheability; note that SvelteKit refuses to let you set Cache-Control on a response that also sets cookies, which is a helpful guard rail and also a common source of surprise DYNAMIC statuses.

Debugging workflow

  1. Establish the per-route baseline. Deploy the sampler in measure-only mode and wait a full traffic cycle — at least twenty-four hours, ideally a week, so weekday and weekend patterns are both represented. Ratios computed over an hour are dominated by whatever crawler happened to run.

  2. Rank routes by absolute waste, not by ratio. A route at 40% serving two hundred requests a day is irrelevant. A route at 88% serving four million is where the money is. Sort by (miss count × response bytes) and work the top of that list.

  3. Classify the status mix per route. BYPASS or DYNAMIC means the response is not cacheable at all and no key work will help. MISS-dominant means keys are not being reused. EXPIRED-dominant means the entry was stored and aged out.

  4. Measure cardinality on MISS-dominant routes. Count distinct normalized keys and distinct raw keys over the same window. The step-by-step method is in debugging low cache hit ratios at the edge.

  5. Change one thing and re-measure against a control. Caching changes interact — raising a TTL and normalizing keys at once means you learn nothing about either.

For confirming a single URL’s behaviour by hand, read the status headers directly with curl:

# Two identical requests, seconds apart: the second should hit.
curl -sSI 'https://example.com/product/123' | grep -iE 'cf-cache-status|x-vercel-cache|cache-status|age|cache-control|vary'
curl -sSI 'https://example.com/product/123' | grep -iE 'cf-cache-status|x-vercel-cache|cache-status|age'

If the second request does not return an age greater than zero, the response was not stored, and the reason is in the cache-control and vary lines from the first request.

Common pitfalls

Symptom Cause Fix
Dashboard shows 95% but origin egress keeps rising Request ratio quoted, byte ratio is much lower Track byte hit ratio as a first-class series alongside request ratio
Ratio dropped overnight with no deploy A crawler or a new marketing campaign added query-parameter traffic Compare raw versus normalized key cardinality before and after the change
Enabling stale-while-revalidate “lowered” the hit ratio STALE counted as a miss Count STALE as a hit; it was served from the edge without origin latency
Route shows 100% hit and is still slow Immutable static assets dominate the sample Exclude _next/static and equivalents from the sampler’s matcher
Every response is DYNAMIC after a framework upgrade A cookie or header read made the route dynamic Find the dynamic API call in the render tree; move it below a cacheable boundary
Hit ratio differs sharply between regions Low-traffic PoPs evict the working set under LRU pressure Enable tiered caching so small PoPs are backed by a shield
Ratio looks fine but p99 latency is bad Aggregation hides a slow, low-ratio route Always break down by route pattern before reading the site-wide number

Runtime-constraints checklist

Frequently Asked Questions

Should I report hit ratio by request or by byte?

Report both, because they answer different questions. Request hit ratio tracks user-perceived latency, since every cache miss costs a user an origin round trip regardless of response size. Byte hit ratio tracks origin egress cost and origin bandwidth, since a single large uncached file can outweigh thousands of small cached ones. Quoting one without naming which is the most common source of disagreement between teams.

Why did my hit ratio fall when I enabled stale-while-revalidate?

Almost certainly because your pipeline classifies a STALE status as a miss. A stale-while-revalidate response is served from the edge with no origin round trip on the user’s path, which is exactly what hit ratio is meant to measure, so it should count as a hit. Fix the classification rather than the caching configuration.

What is a good edge cache hit ratio?

There is no universal number, because the ceiling is set by your traffic distribution rather than your configuration. Immutable static assets should sit above 99 percent. A high-traffic HTML route should reach 90 percent or better. A catalogue of millions of rarely requested pages may be structurally capped near 40 percent, and the right response there is tiered caching and faster origin responses, not header tuning.

How do I tell key fragmentation apart from a short TTL?

Look at the status mix. Fragmentation produces a MISS dominant mix, because each key is requested once and never reused. A short TTL produces an EXPIRED dominant mix, because the entry was stored successfully and simply aged out before the next request arrived. If your provider does not distinguish the two, compare the count of distinct raw keys with the count of distinct normalized keys over the same window.

Does Vary on User-Agent really hurt that much?

Yes. Every distinct user agent string creates a separate cache variant, and the set of user agent strings in the wild is effectively unbounded because of version numbers and device identifiers. A single popular URL can fragment into tens of thousands of variants, which both destroys the hit ratio and consumes cache capacity that other content needed. Normalize to a small device class header instead, and vary on that.

Where should the sampler run so it does not slow requests down?

In edge middleware on the response path, emitting a single structured log line with console dot log and doing no network calls. Log drains on every major provider will ship those lines to your warehouse asynchronously. If you need direct ingestion into an analytics endpoint, batch it behind a background task and sample a small fraction of traffic, because a per request fetch will dominate your CPU budget.