CDN cache performance testing should prove three things together: requests receive the correct content, updates appear within the required deadline, and the origin stays within its capacity limits. Combine stable request keys, content-version assertions, controlled invalidation, and independently measured origin activity. A cache-hit percentage alone is not a release decision. This guide is for performance testers, QA engineers, and SREs working in an authorized environment. Cloudflare and Amazon CloudFront illustrate provider differences; the examples are not client results or benchmarks. This guide covers the critical aspects of CDN performance testing.
Start here: Establish the baseline, probe individual objects, verify an update, reconcile origin counters, and then add load. The accompanying example package contains the complete scripts; the article shows the commands, key assertions, and result interpretation.
Related Blogs
JMeter Distributed Load Testing: Sizing & Bottlenecks
Performance Testing Capacity Planning: From Traffic Forecasts to Load Profiles
Table of Content
- 1. What Should CDN Cache Performance Testing Prove?
- 2. Establish the Cache Policy and Test Baseline
- 3. Verify HIT/MISS with Repeatable GET Requests
- 4. Test Cache-Key Variants, TTL Expiry, and Revalidation
- 5. Verify Invalidation with Content-Version Assertions
- 6. Measure Origin Load Separately from Cache Hit Ratio
- 7. Build a Repeatable CDN Load Test with k6
- 8. Cover Failure Modes and Define Release Gates
1. What Should CDN Cache Performance Testing Prove?
Cache correctness, freshness, and performance
Define separate acceptance criteria. Correctness means the response matches the requested object, user, and representation. Application freshness means a required update appears by its deadline. Performance means the intended workload meets latency and reliability targets without overloading the origin.
Do not confuse application freshness with HTTP freshness. A response can be stale under HTTP caching rules while still containing the current application version. Conversely, a fresh cached response can contain a version that your release now requires you to replace. Verify the version independently.
Separate browser, edge, shield, and origin behavior
Request path: Client or browser to CDN edge to upper-tier or shield cache, where configured, to origin.
Observe the body and timings at the client, cache classifications at the CDN, and request counts and resource use at the origin. CloudFront Origin Shield adds another cache layer and can consolidate simultaneous requests for an object; an edge miss is not automatically an origin fetch.
Keep browser checks separate from command-line probes. Invalidating CDN content does not clear copies already stored in browser or intermediary caches.
2. Establish the Cache Policy and Test Baseline
Select representative objects and document their cache keys
Start with controlled fixtures, then expand to a representative object mix.
- Fixture: Mutable public document /cdn-test/a.json | Required assertion: Correct id and explicit version; updates meet the freshness deadline.
- Fixture: Versioned static asset /cdn-test/app.v1.js | Required assertion: Content stays stable at this URL; a new filename selects a new release.
- Fixture: Private response (Two synthetic users requesting an account endpoint) | Required assertion: No cross-user reuse; no shared storage where the policy prohibits it.
Record the effective cache key: host, path, query parameters, selected headers, cookies, and representation differences. CloudFront cache policies and Cloudflare cache-key rules determine which variations identify separate objects.
For the JSON demonstration, prepare a.json, b.json, and c.json, returning their respective IDs and “version”: “v1”. Explicitly enable caching for this isolated route. An illustrative response policy is Cache-Control: public, max-age=300, with an ETag that changes when the representation changes. Check the effective CDN time to live (TTL) against that policy; this is not a recommendation to cache arbitrary API responses.
Record configuration, telemetry, and safety limits
Capture the configuration revision, key rules, TTLs, stale-serving policy, error caching, shield topology, object sizes, traffic mix, and actual generator locations. Agree on service-level objectives (SLOs), a freshness deadline, origin capacity limits, and a stop procedure.
Inspect overrides as well as headers. A positive CloudFront minimum TTL can cause caching despite origin no-cache, no-store, or private directives. Verify the resulting behavior, especially for private responses.
Keep cold-start, warm-up, steady-state, expiry, and post-invalidation windows separate. Declare which cache layers were cleared. A warm shield means the delivery path is not fully cold even when an edge cache has been cleared.
Result gates are not safety stops. End-of-test thresholds can fail a report after the origin has already been overloaded. Assign an owner or automation to stop every generator when agreed origin request rate, saturation, timeout, or error limits are exceeded. Exercise that stop path before increasing traffic. k6 supports early-abort thresholds, but a client-side abort is not a substitute for origin-side protection.
Keep purge credentials out of load scripts. Correlate using an isolated namespace and UTC windows; add a request header only after checking that it neither changes the cache key nor triggers bypass. Codoid’s performance testing capacity planning guide explains how to prepare the traffic model.
3. Verify HIT/MISS with Repeatable GET Requests
Capture headers, response content, and timings with curl
Use GET, retain the body, and keep the URL and request headers stable. The accompanying assets/probe.sh requires Bash, curl, and Python 3. It makes three bounded requests, saves timestamps, headers, bodies, and timings, and fails unless every response is HTTP 200 with the expected JSON ID and version.
From the example package directory, run:
export RUN_ID='probe-001' export TEST_LOCATION='generator-a' URL='https://your-authorized-cdn-host/cdn-test/a.json' \ EXPECTED_ID=a EXPECTED_VERSION=v1 bash assets/probe.sh
Replace the hostname and location label; register the generator’s actual location separately. A successful local synthetic probe produces a message of this form:
Content checks passed; inspect cache headers in cdn-probe-<generated-id>
That message confirms status and body assertions, not cache behavior. Inspect the saved CF-Cache-Status or X-Cache, Age, Cache-Control, validators, and request/location evidence. The probe neither follows redirects nor adds cache-busting parameters. The first request need not be a MISS if another request or an upper cache has already filled the object.
curl’s time_starttransfer includes connection preparation and the wait for the first byte. It is not origin processing time, and separate curl invocations have separate connection contexts. A faster second request alone does not prove a cache hit.
Interpret provider-specific cache states correctly
Preserve the distinctions below rather than treating every non-HIT as a MISS.
- Evidence: Cloudflare CF-Cache-Status HIT / MISS | State: Cache found / eligible object absent at the observed lookup. Use origin evidence for backend counts.
- Evidence: Cloudflare EXPIRED | State: An expired cached object required an origin response.
- Evidence: Cloudflare REVALIDATED | State: A conditional request confirmed the cached representation was unchanged; it was reused.
- Evidence: Cloudflare STALE | State: A stale cached response was served when the origin could not be contacted. Verify its application revision separately.
- Evidence: Cloudflare UPDATING | State: A stale cached response was served while background refresh was in progress. Verify its application revision separately.
- Evidence: Cloudflare BYPASS / DYNAMIC | State: Respectively, a response-time noncacheability decision or request-time ineligibility for caching.
- Evidence: CloudFront response and logs Hit, Miss, RefreshHit, Error | State: Preserve refresh/error outcomes; reconcile header observations with documented log classifications.
- Evidence: Either provider Missing, composite, or unsupported value | State: Unclassified for this fixture; investigate rather than guess.
Validation is not limited to TTL expiry: a directive such as unqualified no-cache can require it before reuse. Nor does Age identify an application version. Retain the body assertion and check cache-tier context.
4. Test Cache-Key Variants, TTL Expiry, and Revalidation
Validate variants without fragmenting the workload accidentally
Change one key input at a time. For an included parameter such as language=en versus language=fr, check the correct representation before and after warming both variants. For an excluded tracking parameter, check that it neither changes the representation nor creates unintended fragmentation. Apply the same method to configured cookie and header variants.
Request private fixtures as user A, user B, and an unauthenticated user, in both orders and concurrently. Assert identity, permissions, and shared-cache exclusions. Do not remove a representation-defining key field merely to improve hit ratio.
Avoid generating a unique random query string for every request. When the parameter belongs to the key, the test becomes a stream of different objects. Use a bounded corpus and report its achieved popularity distribution.
Scope boundary: Where relevant, test Vary, Accept-Language, and Accept-Encoding against actual normalization and cache-policy settings. The small JSON example does not establish correctness for large assets, Range/206 responses, edge functions, multiple CDNs, or browser validation. Treat these as deployment-specific extensions.
Test expiry boundaries and conditional validation
The unqualified response directives have different purposes: no-cache requires validation before reuse; no-store prohibits storage; private prohibits shared-cache storage. Verify the configured provider behavior as well as the intended HTTP policy.
Record the initial fill time and effective TTL, then probe before and after expiry with an explicit timing tolerance. Repeat once with unchanged content and once with a changed body and validator. CloudFront documents origin 304 Not Modified for an unchanged representation and 200 with a body for a changed one. The viewer need not see the origin’s 304. Cache eviction can also happen before TTL expiry.
Check stale-serving configuration separately. For example, Cloudflare’s documented behavior depends on Origin Cache Control and directives including must-revalidate, no-cache, and s-maxage. Do not assume an identical header combination behaves the same way across providers.
5. Verify Invalidation with Content-Version Assertions
Warm version 1, publish version 2, and invalidate the intended scope
Use a controlled update drill:
- Warm version 1 of the target and an unrelated control at each intended generator location. Save bodies, validators, and cache observations.
- Publish version 2 and verify it through an authorized origin-side or deployment check. Otherwise, the next fill can cache version 1 again.
- Submit the scoped operation. Retain its selector, submission/acceptance times, response, identifier, and completion status where available.
- Probe every targeted variant and the control throughout the observation window. Compare versions, not just cache states.
For a Cloudflare hard-purge drill, follow the documented single-file purge procedure for only /cdn-test/a.json on the authorized hostname; leave b.json unchanged as the control. Custom cache keys may require the API and relevant key components. This is a tightly scoped procedure, not a purge-all instruction.
CloudFront query-string variants need explicit targeting or a correctly scoped wildcard. Tag invalidation requires CacheTagConfig; an origin tag header alone is insufficient. Warm matching and nonmatching tagged controls before evaluating that operation.
Check freshness across locations, variants, and rollback
Cloudflare purge removes matching content, whereas soft invalidation marks retained content stale for revalidation and may allow stale serving. CloudFront describes invalidation as removal from edge caches. Do not impose the same unconditional “next request must be MISS” assertion on all three paths.
Define the time when version 2 becomes required. Start observing during the operation, not only after an administrative completion response. An illustrative test plan might use a 2-second probe interval, a 60-second freshness deadline after acceptance, and a 30-second hold period. These are chosen test settings, not provider guarantees or measured propagation times.
- Observation to retain: Operation | Required evidence: Exact selector, type, accepted/rejected status, identifier, and UTC timestamps.
- Observation to retain: Each location and variant | Required evidence: Generator identity/location, provider response and log location evidence, version, cache state, and probe time.
- Observation to retain: Convergence | Required evidence: Last version-1 observation; first sustained version-2 observation; no version-1 return during the hold period.
- Observation to retain: Control and rollback | Required evidence: Unchanged control content; a separate version-2-to-version-1 rollback drill using the same deadline rules.
A generator label is not proof of the serving edge. Save raw CF-Ray or available CloudFront response-location evidence, and correlate with provider logs such as x-edge-location. Cloudflare routing and tiers affect header interpretation; do not equate every location code with the user-facing cache without checking the topology.
Test incorrect selectors, insufficient permissions, throttled or failed operations, and bounded administrative retries in an authorized environment. A control MISS alone does not prove an overbroad purge; investigate its content and logs. For versioned assets, also verify the HTML or manifest that selects the filename and its rollback.
Observation limit: Success applies to the sampled locations, variants, and window. It does not prove that every CDN edge was tested.
6. Measure Origin Load Separately from Cache Hit Ratio
Define request, byte, and latency measurements precisely
Write the denominator beside every ratio. Here, validated HIT share means correct responses classified as HIT divided by all attempted fixture requests. CloudFront’s CacheHitRate instead uses cacheable requests and excludes specified noncacheable requests and errors. Do not interchange them.
- Isolate /cdn-test/, synchronize clocks, and drain earlier work. Preserve hostname, route, object mix, encoding, generator placement, and offered traffic.
- Disable caching only for the authorized baseline scope, verify the bypass, and retain the policy revision. Do not substitute a direct-origin URL that changes the route. At CloudFront, zero minimum/default/maximum TTL settings disable caching; verify the complete route rather than assuming that headers alone do so.
- Record each viewer window and continue origin collection through an agreed refresh/retry drain. Keep baseline and test windows nonoverlapping.
- Count each origin-received request, including 304, errors, and retries that reach the counting point. Sum body bytes at origin egress, not viewer bytes. Keep pre-origin connection failures in a separate error measurement.
- Reconcile log/export completeness against ingress counters, account for duplicate exports without collapsing real retry attempts, and wait for relevant delayed telemetry. Missing evidence makes the comparison inconclusive, not a zero-load result. CloudFront standard logs can arrive late.
Normalize your origin export to this schema before using the accompanying Python utility; it is not a native CDN log format:
{
"attempt_id": "unique-origin-attempt",
"completed_at": "2026-11-04T11:01:05Z",
"path": "/cdn-test/a.json",
"status": 304,
"body_bytes": 0
}
attempt_id must distinguish individual origin attempts, including retries; do not deduplicate on a viewer request ID. The counting window uses completion timestamps, so drain until in-flight work is accounted for. Record the source byte definition, ingestion cutoff, known gaps, and reconciliation result separately; the parser cannot prove completeness.
The package contains fabricated exports that reproduce the following arithmetic. To count the cache-test fixture, run:
python3 assets/origin-counts.py synthetic/origin-cache.json \ --prefix /cdn-test/ \ --start 2026-11-04T11:00:00Z --end 2026-11-04T11:01:15Z
Its expected synthetic totals are origin_requests = 3000, origin_304 = 1000, and origin_body_bytes = 200000000. Replace the file and window with verified origin data for a real test.
Account for shielding, revalidation, collapsing, and retries
Shielding can satisfy lower-tier misses, simultaneous same-key requests can be collapsed, and validation still performs origin work without necessarily returning a new body. Count these effects rather than estimating them from a viewer MISS percentage.
Illustrative evidence record, not a measured CDN run. Both windows below have 100,000 attempted viewer requests, correct responses, and a hypothetical 100,000-byte full representation. The payload size is not a measurement of the small JSON fixtures.
- Record field: Viewer window, UTC | Cache-disabled baseline: 2026-11-04, 10:00-10:01 | Cache-enabled test: 2026-11-04, 11:00-11:01
- Record field: Origin window end, including drain | Cache-disabled baseline: 10:01:15 | Cache-enabled test: 11:01:15
- Record field: Viewer HIT observations | Cache-disabled baseline: 0 | Cache-enabled test: 90,000
- Record field: Origin full-body fetches | Cache-disabled baseline: 100,000 | Cache-enabled test: 2,000
- Record field: Origin 304 validations | Cache-disabled baseline: 0 | Cache-enabled test: 1,000
- Record field: Total origin-received requests | Cache-disabled baseline: 100,000 | Cache-enabled test: 3,000
- Record field: Origin body bytes, excluding headers | Cache-disabled baseline: 10,000,000,000 | Cache-enabled test: 200,000,000
- Record field: Evidence status | Cache-disabled baseline: Fabricated parser fixture | Cache-enabled test: Fabricated parser fixture
The HIT share is 90%. Origin-request reduction is 97%: 1 – 3000/100000. Body-byte reduction is 98%: 1 – 0.2/10, using decimal gigabytes and identical encoding. The 1,000 validations still count as requests. Real workloads require their own complete counters and an adequate drain duration. Codoid’s API performance testing guide provides broader latency and throughput context.
7. Build a Repeatable CDN Load Test with k6
Use a bounded object corpus and explicit traffic phases
Use the accompanying assets/cdn-cache-v1.1.0.js, targeting the documented k6 2.3.0 baseline with built-in modules. The complete source is provided separately so the setup and interpretation remain readable. It has local mocked checks, not native-k6 or live-provider validation.
Each iteration selects one of the three fixtures using an illustrative 60/30/10 popularity distribution in expectation, performs one GET, and checks status, ID, and version. There are no automatic redirects, random cache-busters, or application-level retries. The constant-arrival-rate executor schedules iterations independently of response duration while virtual users are available; it supplies pacing without an extra sleep.
- Setting: BASE_URL, PROVIDER | Meaning / example: Authorized HTTPS hostname; cloudflare or cloudfront.
- Setting: RUN_ID, TEST_LOCATION | Meaning / example: Required labels; one unique run ID and a registered generator-location label.
- Setting: PHASE, EXPECTED_VERSION | Meaning / example: cold, warmup, steady, or postpurge; required fixture revision.
- Setting: RATE, DURATION | Meaning / example: Requests per second per generator and phase duration; defaults 5 and 30s.
- Setting: PRE_VUS, MAX_VUS | Meaning / example: Preallocated and maximum virtual users; defaults 20 each.
- Setting: MIN_HIT_SHARE, P95_MS | Meaning / example: Illustrative steady HIT-share gate 0.90; aggregate p95 limit 500 milliseconds.
- Setting: GATE_STATES, MIN_STATE_SAMPLES | Meaning / example: Optional state groups, such as HIT, MISS, with a sample-count floor; default floor 100.
- Setting: HIT_P95_MS, MISS_P95_MS | Meaning / example: Per-state limits when those groups are requested; configurable, not universal targets.
Run on each actual authorized generator, assigning its real location in the run manifest. Labels do not create geographical distribution. Two generators configured at five requests per second offer ten requests per second in total.
export BASE_URL='https://your-authorized-cdn-host' export PROVIDER='cloudflare' export TEST_LOCATION='generator-a' export RUN_ID="cdn-$(python3 -c 'import uuid; print(uuid.uuid4().hex)')" mkdir -p "results/$RUN_ID" PHASE=warmup RATE=5 DURATION=30s \ k6 run --out "json=results/$RUN_ID/warmup.json" assets/cdn-cache-v1.1.0.js # Complete the warm-up barrier below before continuing. PHASE=steady RATE=5 DURATION=60s \ GATE_STATES=HIT HIT_P95_MS=100 MIN_STATE_SAMPLES=100 \ k6 run --out "json=results/$RUN_ID/steady.json" assets/cdn-cache-v1.1.0.js
Read the output: a successful native run should exit 0, satisfy the configured correctness/count/latency thresholds, and write the two result files. Preserve the actual exit status, runtime version, and output. These are expected behaviors, not evidence that this run has been performed.
Warm-up barrier: After warm-up, probe every manifest item at every intended generator location. Verify the ID/version and the expected stable cache behavior, and retain provider location evidence. As an illustrative rule, require three consecutive correct HITs per item/location for these public fixtures; a fixture designed to revalidate needs a different criterion. Start measurement only after all checks pass. Recheck when serving-location evidence changes.
For cold-start testing, clear the declared scope and do not warm it with preliminary probes. Repeated requests naturally fill the cache, so report a cold-start transition rather than an all-MISS benchmark. For post-purge checks, set EXPECTED_VERSION=v2 when version 2 becomes mandatory. The script does not perform invalidation or assess its convergence deadline.
Add content assertions, cache-state metrics, and thresholds
The script emits cache_requests, content_errors, cache_unknown, validated_hit_share, and a tagged cache_ms trend. It keeps correctness failures separate from cache classifications. Unknown or composite header values fail the classification gate rather than being guessed.
This excerpt from the full script creates optional per-state latency and count gates:
for (const state of gateStates) {
const filter = `cache_state:${state},outcome:correct`;
thresholds[`cache_ms(${filter})`] = ['p(95)<${positiveInt(state, 'P95_MS', 500)}'];
thresholds[`cache_requests(${filter})`] = [`count>=${minSamples}`];
}
cache_state and outcome are attached to the custom samples after receiving a response. They are not retroactively attached to built-in HTTP metrics. Apply the filters to cache_ms and cache_requests, not an untagged http_req_duration.
cache_ms uses res.timings.duration, which excludes initial connection setup. Inspect connection/TLS metrics separately. The script also gates dropped iterations, which represent scheduled iterations that did not start, and reports overall latency without allowing it to replace state-specific checks.
Use CAPTURE_EVIDENCE=1 only during low-rate diagnostics to log available request/location headers. Preserve raw probe headers and provider log correlation as the evidence of serving location. Full request IDs stay in logs, not metric labels. Keep measured-load logging and data-export overhead under review.
Analyze a selected run and generator with the supplied Python tool. This command uses synthetic points, not CDN observations:
python3 assets/analyze-cache.py synthetic/cache-points.jsonl \ --run synthetic-demo --location synthetic-location \ --minimum 100 --limits HIT=100,MISS=400
- Synthetic group: Correct HIT (96 observations, n) | p95: 42 ms | Required n: 100 | Interpretation: Passes this illustrative group gate.
- Synthetic group: Correct MISS (40 observations, n) | p95: 180 ms | Required n: 100 | Interpretation: Inconclusive: insufficient samples, despite the low observed p95.
The analyzer exits 2 for insufficient evidence, 1 for a detected failure, and 0 when the requested groups pass. It uses linear-interpolated p95. This is one result slice, not a complete workload or origin-capacity verdict. A minimum of 100 is an illustrative screening floor, not a statistical confidence guarantee. Choose larger samples and repeat runs for release decisions.
Enable a MISS gate only in a phase intended to produce enough misses. Do not increase traffic beyond authorization just to satisfy a count. A failed k6 count threshold means the evidence requirement was not met; it does not, by itself, establish a product latency defect.
Related Blogs
Cloud Performance Testing with Apache JMeter: A Practical Guide
Artillery Load Testing: Complete Guide to Performance Testing with Playwright
8. Cover Failure Modes and Define Release Gates
Use a cache-performance test matrix
Retain configuration, actions, expected outcomes, and evidence for each applicable row.
- Scenario: Cold and warm corpus | Action: Clear declared scope; request; repeat. | Assertion and evidence: Correct versions; explain transitions and actual origin work.
- Scenario: Cache-key variants | Action: Change one included/excluded input. | Assertion and evidence: Correct representation; no unintended sharing or fragmentation.
- Scenario: Expiry and validation | Action: Probe around TTL with unchanged, then changed content. | Assertion and evidence: Expected validation path and required version; origin 304/body evidence.
- Scenario: Scoped invalidation | Action: Update selected objects; retain a control. | Assertion and evidence: Target refreshes by deadline; control content remains correct.
- Scenario: Concurrent refill | Action: Burst same-key requests after scoped clearing. | Assertion and evidence: Correct responses and origin peak within the limit.
- Scenario: Origin fault and stale boundary | Action: Inject a controlled failure; extend beyond permitted stale time. | Assertion and evidence: Allowed stale behavior or expected errors; no prohibited reuse.
- Scenario: Cached error recovery | Action: Restore an object after an eligible cached error. | Assertion and evidence: Success returns by the recovery deadline.
- Scenario: Private responses | Action: Alternate and concurrently request as two users. | Assertion and evidence: No cross-user content; permissions and exclusions hold.
- Scenario: Administrative failure and rollback | Action: Exercise failed invalidation; restore a prior revision. | Assertion and evidence: Failure is visible; bounded retries and rollback convergence are evidenced.
CloudFront can continue serving a cached error after the origin recovers. Test the error-cache duration rather than stopping when the origin becomes healthy.
Diagnose failures and retain release evidence
- Symptom: Persistent misses | Evidence to inspect: Effective key, policy, eligibility, location | Next check: Is the workload creating new keys or reaching different caches?
- Symptom: Wrong version after invalidation | Evidence to inspect: Origin version, selectors, operation record, downstream cache | Next check: Did the target refresh, and was the correct origin version available?
- Symptom: High origin work despite strong hit share | Evidence to inspect: 304s, retries, bytes, object sizes, metric denominators | Next check: Which work remains outside fresh viewer hits?
- Symptom: Fast but incorrect responses | Evidence to inspect: IDs, users, variants, versions | Next check: Treat correctness as a failure before tuning speed.
- Symptom: Low latency below intended load | Evidence to inspect: Actual request counts and dropped iterations | Next check: Establish generator capacity and achieved load before claiming a pass.
Use one result record per phase/location: run ID, configuration and fixture revisions, offered/achieved load, content failures, state counts and p95, observed serving locations, invalidation timestamps where applicable, reconciled origin counters, and verdict with evidence links.
Pass requires all applicable correctness, freshness, performance, workload, and origin-capacity gates. Fail means an observed contract violation, such as cross-user leakage, prohibited stale serving, a missed version deadline, or overload. Inconclusive means missing samples, unclassified responses, incomplete telemetry, or an unachieved workload prevents the decision. Missing evidence must not become a pass.
Final takeaway: a defensible CDN result links the response version, cache behavior, and measured origin work in the same evidence record. Preserve failed and inconclusive results rather than letting an attractive aggregate hit ratio hide them. Effective CDN cache performance testing requires this holistic view. For support planning and executing load and capacity validation, explore Codoid’s performance testing services or reach out to our team to discuss your CDN performance testing requirements.
Comments(0)