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-Originor 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.
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.
Verifying it works
- Hit share per route should rise toward the target you set. Watch the share, not the absolute count, which moves with traffic.
- p75 TTFB overall should fall by roughly the modelled amount. A smaller move means the newly cached requests were the fast ones.
- 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.
- Confirm freshness is still acceptable. A hit ratio bought with a TTL nobody agreed to shows up later as a stale-content incident.
- 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”.
Edge cases and gotchas
DYNAMICandBYPASSare 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
unknownshare 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.
Related
- FCP & TTFB Analysis — the metric, its phases and the wider debugging workflow.
- Reducing TTFB with Edge Caching — the caching changes this segmentation tells you to make.
- Self-Hosted Beacon Collection — getting an extra cohort field through the pipeline safely.