---
title: "CDN and Cache Headers: Load Testing Cache Hits, Misses, and Stampede Risk"
description: "Exercise CDN edge behavior, validate Age/TTL headers, and detect thundering herds when caches expire together."
publishDate: "2026-09-03"
draft: false
keyword: "cdn load testing"
intent: "MOFU"
cta: "Use Performate to replay cache-aware journeys against staging edges without improvising curl loops."
tags: ["k6","load-testing","api-performance","cdn"]
---

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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching)).

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](/en/blog/geo-distributed-load-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-Encoding` or 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](https://k6.io/docs/using-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 `Age` and `X-Cache` to 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.

```javascript
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](/en/blog/api-bottleneck-analysis)).
- 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 `Vary` headers 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**

1. **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.
2. **Parameterize asset URLs** from a CSV of product IDs matching production distributions. *Problem solved:* realistic key diversity without hand-editing paths each sprint.
3. **Add checks for Cache-Control and Age** in the visual editor. *Problem solved:* non-script authors validate headers consistently.
4. **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.
5. **Filter integrated report by hit_class tag** and compare p95 edge vs origin. *Problem solved:* one export for CDN and backend teams.
6. **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.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 scenarios](https://k6.io/docs/using-k6/scenarios/)
