Segmenting TTFB by CDN Cache Status

An origin-level TTFB number is a blend of two completely different experiences: requests answered at the edge in under 150 ms, and requests that travelled to your origin and waited for a database. Averaging them produces a figure that describes neither. This guide sits under FCP & TTFB Analysis and covers how to capture the cache status of the document request in the browser, how to store it as a cohort key, and what the resulting split actually tells you to do.

Once the split exists, TTFB work stops being vague. The question changes from “why is our server slow” to “why is 34% of our traffic missing the cache”, which has a finite list of causes and a measurable target.

Prerequisites

  • A CDN in front of the document, with a cache status response header — every major provider sets one.
  • Control over the response headers on the HTML document, including a Timing-Allow-Origin or same-origin serving so the browser exposes timing detail.
  • A beacon pipeline that accepts an extra low-cardinality field, as described in Self-Hosted Beacon Collection.

How to capture and use the cache status

Step 1 — Know which header your CDN sets

Provider Header Values you will see
Cloudflare cf-cache-status HIT, MISS, EXPIRED, REVALIDATED, BYPASS, DYNAMIC
Fastly x-cache HIT, MISS, HIT, MISS (edge, shield)
Akamai x-cache TCP_HIT, TCP_MISS, TCP_REFRESH_HIT
CloudFront x-cache Hit from cloudfront, Miss from cloudfront
Varnish x-cache (custom) Whatever your VCL sets

Normalise these to a small set — hit, stale, revalidated, miss, bypass — before storing. Five buckets aggregate cleanly; twenty provider-specific strings do not.

Step 2 — Get the value into the browser

The document response headers are not readable from JavaScript, so the value has to be surfaced deliberately. Two approaches, in order of preference.

Echo it into the HTML at the edge. A worker or edge function that already handles the response can write the status into a meta tag or a data attribute.

// Edge worker: stamp the cache status where the page can read it.
export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    const status = (response.headers.get('cf-cache-status') || 'unknown').toLowerCase();
    return new HTMLRewriter()
      .on('html', {
        element(el) { el.setAttribute('data-cache', status); },
      })
      .transform(response);
  },
};

Or read it from a same-origin subresource. Where you cannot modify the document, a tiny same-origin request with Timing-Allow-Origin exposes nextHopProtocol and transfer sizes through Resource Timing, and a custom header can be echoed into its body.

Step 3 — Attach it to the beacon

// cache-cohort.js — read once, attach to every metric from this page load.
const cacheStatus = document.documentElement.dataset.cache || 'unknown';

// Navigation Timing gives the TTFB itself; the cohort key gives it meaning.
const nav = performance.getEntriesByType('navigation')[0];

export function ttfbBeacon() {
  return {
    metric: 'TTFB',
    value: Math.round(nav.responseStart),
    cacheStatus,
    // Two more fields that make the analysis far sharper:
    protocol: nav.nextHopProtocol,             // h2, h3 — connection reuse story
    navType: nav.type,                         // navigate, reload, back_forward
    route: window.__route,
  };
}

navType is worth carrying because a back_forward navigation is served from the back/forward cache and has a near-zero TTFB that would otherwise flatter your hit-rate analysis — the handling is covered in Handling bfcache Restores in Web Vitals.

Where the traffic actually landsSearch bypasses by design. The product template does not — a third of its traffic is going to origin, and that is where the TTFB work is.Where the traffic actually landsShare of document requests by normalised cache status, 28 daysHome91%Category74%12%11%Product38%14%32%16%Search results12%82%HitStale or revalidatedMissBypass
Search bypasses by design. The product template does not — a third of its traffic is going to origin, and that is where the TTFB work is.

Step 4 — Query the split, then set a hit-ratio target

-- p75 TTFB and volume by cache status and route. The volume column is what
-- turns this from an observation into a priority list.
SELECT
  route,
  cache_status,
  count()                             AS requests,
  count() / sum(count()) OVER (PARTITION BY route) AS share,
  quantileTDigest(0.75)(value)        AS p75_ttfb
FROM rum_events
WHERE metric = 'TTFB' AND ts >= now() - INTERVAL 28 DAY
GROUP BY route, cache_status
ORDER BY route, requests DESC;

The improvement available from raising the hit ratio is arithmetic once you have this table: multiply the share of misses by the difference between the miss and hit p75. A template at 38% hit rate with a 1,020 ms gap between hit and miss has roughly 630 ms of TTFB available from caching alone — usually far more than any origin optimisation would return.

Step 5 — Attack the misses by cause

Miss cause Signature Fix
Cache key includes a query string Misses correlate with campaign traffic Strip or allowlist query parameters
Cookie on the request Misses cluster on logged-in sessions Vary only on the cookies that matter
Short TTL Misses spread evenly over time Raise TTL, add stale-while-revalidate
Rarely visited URLs Misses spread across many unique paths Accept, or pre-warm the top N
Vary header explosion Misses everywhere, hit ratio ceiling Reduce the Vary surface

The single most common finding is the first one: analytics parameters like utm_source in the cache key mean every campaign click is a guaranteed miss, which is exactly the traffic a marketing team is measuring.

p75 TTFB by cache status on the product templateServing stale while revalidating is nearly as fast as a hit and removes the revalidation penalty entirely for the user who triggered it.p75 TTFB by cache status on the product template14 days, mobile sessionsHit132 msStale while revalidating148 msRevalidated at origin580 msMiss1,150 msBypass1,320 ms0 ms500 ms1,000 ms1,500 ms2,000 msGood 800 ms
Serving stale while revalidating is nearly as fast as a hit and removes the revalidation penalty entirely for the user who triggered it.

Verifying it works

  1. Hit share per route should rise toward the target you set. Watch the share, not the absolute count, which moves with traffic.
  2. p75 TTFB overall should fall by roughly the modelled amount. A smaller move means the newly cached requests were the fast ones.
  3. Check the miss p75 did not rise. Raising the hit ratio moves easy requests off the origin, leaving a harder residue; the origin p75 for misses often gets worse even as the total improves.
  4. Confirm freshness is still acceptable. A hit ratio bought with a TTL nobody agreed to shows up later as a stale-content incident.
  5. Cross-check against FCP. TTFB improvements should propagate; if FCP did not move, render-blocking work is now the constraint — see Reducing Render-Blocking CSS for FCP.

Turning the split into a weekly number

The cache-status dimension is most useful when it collapses into one figure a team can watch. Two candidates work, and they answer different questions.

Effective hit ratio — the share of document requests served without going to origin, counting stale-while-revalidate as a hit because the user did not wait. This is the operational number: it moves when a cache key changes, a TTL is raised, or a new route ships uncached.

Modelled TTFB headroom — the share of misses multiplied by the gap between the miss and hit percentiles, summed across routes. This is the prioritisation number: it says how many milliseconds are available from caching work before anyone touches the origin.

-- One row per week per route: the two numbers worth trending.
SELECT
  toStartOfWeek(ts)                                              AS week,
  route,
  countIf(cache_status IN ('hit', 'stale')) / count()            AS effective_hit_ratio,
  (countIf(cache_status IN ('miss', 'bypass')) / count())
    * (quantileTDigestIf(0.75)(value, cache_status = 'miss')
       - quantileTDigestIf(0.75)(value, cache_status = 'hit'))   AS modelled_headroom_ms
FROM rum_events
WHERE metric = 'TTFB' AND ts >= now() - INTERVAL 90 DAY
GROUP BY week, route
ORDER BY week DESC, modelled_headroom_ms DESC;

Ranking routes by modelled_headroom_ms rather than by raw p75 puts the work in the order that returns the most milliseconds per change, which is rarely the same order as “worst first”.

Why the misses are missingTwo fifths of the misses are analytics parameters in the cache key — campaign traffic guaranteed to go to origin, which is exactly the traffic being measured.Why the misses are missingShare of origin requests by identified cause, product templateCampaign query parameters in the key41%Cookie on the request24%TTL expired19%Rarely visited URL, first request11%Vary header explosion5%0%12.5%25%37.5%50%
Two fifths of the misses are analytics parameters in the cache key — campaign traffic guaranteed to go to origin, which is exactly the traffic being measured.

Edge cases and gotchas

  • DYNAMIC and BYPASS are not failures. A route that must not be cached will always be there. Exclude it from hit-ratio targets rather than pretending it can improve.
  • Cache status describes the edge, not the shield. Multi-tier CDNs can report a miss at the edge that was a hit at the shield, with a very different TTFB. Capture both values if your provider exposes them.
  • Service workers intercept before the network. A page served from a service worker cache reports a TTFB of near zero and no cache status at all. Tag those sessions separately.
  • Prerendered and prefetched navigations have a TTFB measured from a request that happened before the user clicked. They are genuinely fast, and they will flatter your numbers if they are not labelled.
  • Personalised HTML kills caching by design. If the document varies per user, the fix is not a cache header but moving personalisation to a client-side or edge-composed fragment.
  • Header case and value drift. Providers change value spellings between versions. Normalise defensively and alert on an unknown share that starts growing.

FAQ

Why can I not just read the response header in JavaScript?

Document response headers are not exposed to the page. You have to echo the value into the HTML at the edge, or read it from a same-origin subresource whose headers you can inspect.

What hit ratio should I aim for?

For cacheable document routes, above 90% is achievable and 70% usually indicates a cache-key problem. Routes that are personalised or search-driven should be excluded from the target entirely.

Does stale-while-revalidate help TTFB?

Substantially. It serves the cached copy immediately while refreshing in the background, so the user who triggers the revalidation does not pay for it. In the data above it performs within 16 ms of a plain hit.

Should cache status be a cohort key everywhere, or only for TTFB?

Carry it on every beacon. LCP and FCP inherit TTFB, so being able to split those by cache status explains a large part of their variance too.