Skip to content

Cache hit/miss testing

Cache hit ratio is the fundamental health metric for a CDN. A high hit ratio means most requests are served from the edge without touching your origin — exactly what you want. A low hit ratio means either your Cache-Control headers are misconfigured, your cache keys are too narrow, or your content changes faster than users can warm the cache. This page shows how to measure hit ratio, assert on cache-state headers, and compare warm vs cold cache latency using MaxoPerf.

  • Read the CDN testing overview to understand the full test landscape.
  • Confirm that your CDN returns meaningful Cache-Control, Age, or X-Cache response headers. Run a curl -I https://your-cdn-url/asset.js to verify before writing a test.
  • Have a smoke test passing against your CDN URL first.

Before writing assertions, know what each header means:

HeaderSourceWhat it tells you
Cache-ControlOrigin serverCaching directives — max-age, s-maxage, no-cache, no-store, public, private.
AgeCDNSeconds the response has been in the cache. Age: 0 on the first hit; increasing on subsequent hits. A missing or zero Age on repeated requests suggests the response is not being cached.
X-CacheCDN (provider-specific)HIT or MISS. CloudFront uses X-Cache: Hit from cloudfront. Fastly uses X-Varnish-Cache. Akamai uses X-Cache: TCP_HIT. Check your provider’s documentation.
X-Cache-HitsFastly / some CDNsNumber of times this edge has served the cached response — useful for confirming warm state.
Cf-Cache-StatusCloudflareHIT, MISS, EXPIRED, STALE, BYPASS, DYNAMIC — very granular.

The most informative CDN test separates two phases:

  1. Cold run — send one request per URL to prime the cache. Expect MISS on all responses. Record latency; this is your origin baseline.
  2. Warm run — send concurrent load after the cache is primed. Expect HIT on most responses. Record latency; this is your edge performance.

The ratio of warm to cold latency reveals cache value: if warm TTFB is 20 ms and cold is 200 ms, the CDN is absorbing 90 % of the cost.

The following Taurus YAML runs a warm-cache load test and asserts that:

  • The HTTP status code is 200.
  • The response contains a Cache-Control header with public.
  • The response Age is greater than zero (i.e., served from cache, not origin).
execution:
- concurrency: 50
ramp-up: 30s
hold-for: 3m
scenario: cdn-warm-cache
scenarios:
cdn-warm-cache:
default-address: https://cdn.example.com
requests:
- label: homepage-js-bundle
url: /static/app.js
method: GET
assert:
- equals:
subject: http-code
value: '200'
- contains:
subject: headers
value: 'public' # Cache-Control: public, max-age=...
- not-contains:
subject: headers
value: 'no-store' # guard against accidental no-store
- label: hero-image
url: /static/hero.webp
method: GET
assert:
- equals:
subject: http-code
value: '200'
- contains:
subject: headers
value: 'X-Cache: HIT' # adjust to your CDN's header name
- label: api-response-cached
url: /v1/catalog/featured
method: GET
assert:
- equals:
subject: http-code
value: '200'

k6 gives precise control over per-header checks and lets you record cache state for custom reporting:

import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 },
{ duration: '3m', target: 50 },
{ duration: '30s', target: 0 },
],
thresholds: {
http_req_duration: ['p(95)<100'], // CDN hits should be fast
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const res = http.get('https://cdn.example.com/static/app.js', {
tags: { name: 'cdn-asset' },
});
check(res, {
'status 200': (r) => r.status === 200,
'cache hit': (r) => (r.headers['X-Cache'] || '').includes('HIT'),
'cache-control public': (r) => (r.headers['Cache-Control'] || '').includes('public'),
'age is positive': (r) => parseInt(r.headers['Age'] || '0', 10) > 0,
});
sleep(0.5);
}

Run two separate MaxoPerf tests to compare:

  1. Cold test — one VU, one pass through all asset URLs. Note the average TTFB — this is origin RTT.
  2. Warm test — ramp to production VU count after the cold priming test has finished. Note p50 and p95 TTFB.

After your run, open the Overview tab and look at:

SignalWhat it means
p50 TTFB < 30 msResponses are being served from a nearby edge node.
p50 TTFB > 100 msMost responses are coming from origin. Check Age header assertions — they may be failing.
Assertion failure rate > 0 %Some responses are bypassing the cache. Drill into the Log tab to see which URLs failed which assertion.
High p99 relative to p50Occasional cache misses causing long-tail latency spikes. May indicate TTL expiry mid-test.

Do:

  • Warm the cache before measuring steady-state hit latency.
  • Label requests by asset type or URL group — the per-label breakdown in MaxoPerf makes it easy to spot which resource category has the worst hit ratio.
  • Use assertion failure rate as a proxy for cache miss rate when X-Cache headers are available.

Don’t:

  • Measure cache performance from a single geographic location — edge nodes are regional. A HIT in us-east-1 does not mean a HIT in eu-west-1. See Multi-region edge performance.
  • Confuse a 200 response with a cache hit. The CDN may return 200 from origin on every miss. You need the X-Cache or Age header to distinguish.
  • Forget to check the Vary header on responses — a misconfigured Vary: User-Agent can fragment the cache into per-client silos with near-zero hit ratio. See Cache key and Vary testing.