By Lucas Yoris · Performate
CDN and Cache Headers: Load Testing Cache Hits, Misses, and Stampede Risk
Exercise CDN edge behavior, validate Age/TTL headers, and detect thundering herds when caches expire together.
Your CDN dashboard shows 95% hit ratio in production—but the last load test hammered one static asset URL for ten minutes. That test proved nothing about edge behavior: hit and miss paths differ by orders of magnitude in latency, origin load, and error signatures (HTTP caching overview).
CDN and cache header load testing validates realistic cache key diversity, TTL expiry behavior, and stampede risk when many clients miss together. In this guide you will learn why Age and Cache-Control matter under concurrency, how to tag hit classes in k6, and when to simulate coordinated expiry.
Why cache behavior changes performance—not just bandwidth
Under load, caching layers shift the bottleneck:
- Edge hits serve from PoP memory—sub-millisecond to low tens of ms depending on geography (geo testing).
- Edge misses fan out to origin—suddenly your API tier sees marketing-traffic spikes.
- Stale-while-revalidate paths return fast but trigger background origin fetches—latency looks fine while origin RPS climbs silently.
- Vary headers multiply cache keys—one URL with different
Accept-Encodingor auth cookies creates parallel cold paths.
Hammering a single URL produces a warm-cache fantasy. Production serves thousands of keys with staggered TTLs.
When hit ratio lies
A high aggregate hit ratio hides tail misses on high-value pages. Load tests must vary URLs or cache keys from analytics distributions—not one hero asset.
Assert response headers via k6 checks while tagging hit_class:edge|origin|unknown based on Age, X-Cache, or CF-Cache-Status values your CDN exposes.
Practical k6 implementation: mixed hit/miss traffic with header checks
Model a realistic mix of cacheable assets drawn from a CSV or weighted random selection. Validate headers per response class.
Example script (illustrative—not a production-ready test). Uses fictional CDN headers and asset paths.
What this example demonstrates:
- Varied cache keys: random asset IDs from a shared pool simulate many URLs, not one hot object.
- Header-based hit classification: checks parse
AgeandX-Cacheto tag metrics by hit class. - Parallel miss burst scenario: a second scenario hammers fresh URLs to simulate coordinated cold starts.
- Separate thresholds: edge hits expect lower p95 than origin misses—aggregate latency hides regressions.
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const BASE = __ENV.CDN_BASE || 'https://cdn.staging.example.com';
const assets = new SharedArray('assets', () =>
Array.from({ length: 200 }, (_, i) => `/static/product-${1000 + i}.webp`)
);
function classifyHit(res) {
const age = Number(res.headers['Age'] || 0);
const xCache = (res.headers['X-Cache'] || '').toLowerCase();
if (xCache.includes('hit') || age > 0) return 'edge';
if (xCache.includes('miss')) return 'origin';
return 'unknown';
}
export const options = {
scenarios: {
steady_mix: {
executor: 'constant-arrival-rate',
rate: 80,
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 20,
maxVUs: 60,
exec: 'fetchAsset',
},
miss_burst: {
executor: 'constant-arrival-rate',
rate: 30,
timeUnit: '1s',
duration: '2m',
startTime: '5m',
preAllocatedVUs: 15,
maxVUs: 40,
exec: 'fetchColdAsset',
},
},
thresholds: {
'http_req_duration{hit_class:edge}': ['p(95)<120'],
'http_req_duration{hit_class:origin}': ['p(95)<600'],
http_req_failed: ['rate<0.01'],
},
};
export function fetchAsset() {
const path = assets[Math.floor(Math.random() * assets.length)];
const res = http.get(`${BASE}${path}`, { tags: { asset: 'warm_pool' } });
const hitClass = classifyHit(res);
check(res, {
'status 2xx': (r) => r.status >= 200 && r.status < 300,
'cache-control present': (r) => !!r.headers['Cache-Control'],
});
res.request.headers['X-Hit-Class'] = hitClass; // for custom tagging via http.batch patterns
sleep(0.1);
}
export function fetchColdAsset() {
const coldPath = `/static/campaign-${__VU}-${__ITER}.jpg`;
const res = http.get(`${BASE}${coldPath}`, { tags: { asset: 'cold', hit_class: 'origin' } });
check(res, { 'cold fetch 2xx': (r) => r.status >= 200 && r.status < 300 });
sleep(0.05);
}
Patterns that work
- Warm caches with a low-rate pre-test or dedicated
setup()fetch pass before measuring hits. - Coordinate TTL expiry in safe staging by flushing subsets or using short TTL test assets—watch origin spikes (bottleneck guide).
- Compare edge vs origin timings in reports; if edges saturate first, tune PoPs—not application GC.
Anti-patterns to avoid
- Testing only cache hits because "that's what users see"—miss storms cause outages.
- Ignoring
Varyheaders that fragment cache effectiveness. - Using production CDN purge APIs during unapproved windows.
Decision framework: steady mix vs stampede simulation
| Situation | Recommended action |
|---|---|
| Validating everyday traffic | Weighted random URLs from analytics; tag hit class |
| New campaign launch with fresh assets | Cold-URL burst scenario after steady warm-up |
| TTL alignment across asset classes | Staged expiry test in staging with short TTL fixtures |
| CDN config change (PoP, tiered cache) | Re-run identical mix; compare origin RPS delta |
| Auth-varying cache keys | Separate scenarios per cookie/header variant |
Use steady mix if you need regression baselines on normal cache behavior.
Use miss burst if you must prove origin survives coordinated cold starts.
Use header assertions if CDN misconfiguration returns 200 with wrong Cache-Control—silent perf debt.
Observability, documentation, and next steps
- Document CDN vendor, PoP regions, and header names used for hit classification.
- Record hit/miss ratio per scenario—not just aggregate http_req_duration.
- Alert when origin RPS during test exceeds agreed fraction of origin capacity.
- Correlate k6 tags with CDN analytics dashboards for the same window.
- Archive asset pool definitions so reruns use identical key distributions.
How Performate simplifies CDN-aware load testing
Example: replay cache-aware journeys without curl loops
- Import a collection with static asset GETs plus API calls that set cache-varying headers. Problem solved: one workspace covers HTML, API, and CDN paths.
- Parameterize asset URLs from a CSV of product IDs matching production distributions. Problem solved: realistic key diversity without hand-editing paths each sprint.
- Add checks for Cache-Control and Age in the visual editor. Problem solved: non-script authors validate headers consistently.
- Run steady mix, then duplicate scenario as miss burst with fresh URL pattern. Problem solved: stampede tests are a parameter change, not a new repo.
- Filter integrated report by hit_class tag and compare p95 edge vs origin. Problem solved: one export for CDN and backend teams.
- Export k6 script for CI smoke with low-rate header checks after CDN config changes.
Closing takeaway
CDN performance is a mix of hits and misses, not a single warm URL. Vary cache keys realistically, assert headers under load, and treat origin spikes during miss bursts as release gates.
Run this week's mixed hit/miss scenario against staging before your next CDN or TTL change—and note where origin RPS crosses your agreed ceiling.
Ready to optimize your API performance?
Use Performate to replay cache-aware journeys against staging edges without improvising curl loops.