Testland
Browse all skills & agents

cdn-cache-purge-tests

Wraps CDN cache-purge testing patterns for Cloudflare (POST /zones/{zone_id}/purge_cache, single-file / everything / cache-tags / hostname / prefix), Fastly (POST purge-by-key / purge-all, surrogate-keys via Surrogate-Key header), and CloudFront (CreateInvalidation API + paths). Covers end-to-end test patterns (write origin → trigger purge → assert edge serves fresh), purge-propagation delay testing (typically 1-30s globally), surrogate-key + cache-tag patterns for group-purge, and Cache-Status header verification (cf-cache-status: HIT/MISS/BYPASS). Also owns the client tier: browser-side Cache-Control verification with Playwright (served-from-cache via CDP, ETag 304 round-trips, Workbox service-worker strategies, reload semantics) in references/browser-cache-control.md. Use when designing or auditing CDN cache-invalidation workflows or browser-tier caching behaviour in E2E tests.

Install with skills.sh (any agent)

npx skills add testland/qa --skill cdn-cache-purge-tests
View source

cdn-cache-purge-tests

Overview

CDN cache-purge tests verify that the write-origin-then-invalidate-edge sequence works end-to-end - the most-likely-broken cache integration in real deployments.

Per developers.cloudflare.com/cache/how-to/purge-cache/ (opens in new window), Cloudflare offers five purge methods (Single-file, Everything, Cache-tags, Hostname, Prefix). Fastly's surrogate-key pattern and CloudFront's invalidation API offer similar shapes.

When to use

  • Verifying a new purge integration works end-to-end.
  • Regression-testing the write-origin-then-purge sequence.
  • Auditing existing purge logic before a deploy.
  • Investigating "users see stale content after a write."

Authoring

Cloudflare - purge by URL

curl -X POST \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"files":["https://example.com/api/users/1"]}'

Response:

{ "success": true, "result": { "id": "...." } }

Cloudflare - purge by cache-tag (Enterprise)

curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  --data '{"tags":["user-1","posts-feed"]}'

The response headers from the origin must have included Cache-Tag: user-1, posts-feed for this to work.

Fastly - purge by surrogate-key

curl -X POST "https://api.fastly.com/service/${SERVICE_ID}/purge/${SURROGATE_KEY}" \
  -H "Fastly-Key: ${FASTLY_API_TOKEN}"

Origin responses include Surrogate-Key: user-1 posts-feed (space-separated).

CloudFront - invalidation

aws cloudfront create-invalidation \
  --distribution-id ${DIST_ID} \
  --paths "/api/users/1" "/api/users/1/*"

Cloud-side wait + propagation. Returns an InvalidationId; poll status via get-invalidation.

Running - end-to-end test

The canonical purge test:

import requests, time

def test_write_then_purge_serves_fresh():
    # 1. Origin write
    origin_response = requests.post(
        "https://origin.example.com/api/users/1",
        json={"name": "Alice"},
        headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    )
    assert origin_response.status_code == 200

    # 2. Verify edge has the *old* value (might or might not - cache hit)
    edge_before = requests.get("https://example.com/api/users/1")
    cache_status_before = edge_before.headers.get("cf-cache-status")

    # 3. Trigger purge
    purge = requests.post(
        f"https://api.cloudflare.com/client/v4/zones/{ZONE_ID}/purge_cache",
        headers={"Authorization": f"Bearer {CF_API_TOKEN}"},
        json={"files": ["https://example.com/api/users/1"]},
    )
    assert purge.json()["success"]

    # 4. Wait for global propagation (Cloudflare typically <30s)
    time.sleep(10)

    # 5. Verify edge fetches fresh from origin
    edge_after = requests.get("https://example.com/api/users/1")
    cache_status_after = edge_after.headers.get("cf-cache-status")

    # Either MISS (just fetched) or HIT (re-cached fresh value)
    assert cache_status_after in ("MISS", "HIT", "EXPIRED")
    assert edge_after.json()["name"] == "Alice"

Cache-Status header verification

HeaderCDNCommon values
cf-cache-statusCloudflareHIT, MISS, EXPIRED, BYPASS, DYNAMIC, REVALIDATED
x-cacheFastlyHIT, MISS, HIT-CLUSTER, HIT-CLUSTER-WAIT
x-cacheCloudFrontHit from cloudfront, Miss from cloudfront
ageRFC 9111 standardSeconds since cached

Multi-region propagation

EDGES = [
    "https://example.com",     # default
    "https://eu.example.com",  # geo-routed
    "https://ap.example.com",
]

def test_purge_propagates_globally():
    # Pre-cache in each region
    for url in EDGES:
        requests.get(url + "/api/users/1")

    # Trigger purge
    purge_url(API, "/api/users/1")

    # Wait for global propagation
    time.sleep(30)

    # Verify each region serves fresh
    for url in EDGES:
        r = requests.get(url + "/api/users/1")
        assert r.json()["name"] == "Alice"

Client-tier cache-control

The purge test proves the edge refreshes; the client tier can still serve stale from the browser's own cache. Browser-side verification - asserting the browser acts on Cache-Control as intended (served-from-cache via CDP, ETag If-None-Match → 304 round-trips, Workbox service-worker strategies, normal-vs-hard reload semantics) with Playwright across the Chromium / Firefox / WebKit matrix - is in references/browser-cache-control.md, with deeper recipes in references/playwright-cache-recipes.md. Asserting only which header the server emits needs no browser - keep that in the project's HTTP-level runner.

Parsing results

FieldUse
cf-cache-status: HITServed from edge cache
cf-cache-status: MISSOrigin pull just happened
cf-cache-status: EXPIREDStale; revalidated from origin
cf-cache-status: BYPASSCache deliberately skipped (e.g., uncacheable response)
cf-cache-status: DYNAMICNot cached at all
age: NPer RFC 9111: seconds since cached

For tests: assert on the transition (HIT → MISS after purge), not on the absolute state.

CI integration

jobs:
  cdn-purge-smoke:
    if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Write to origin
        env:
          ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
        run: ./scripts/write-test-fixture.sh
      - name: Purge edge
        env:
          CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
        run: ./scripts/purge-test-paths.sh
      - name: Verify edge serves fresh
        run: pytest tests/cdn/test_purge_propagation.py

Anti-patterns

Anti-patternWhy it failsFix
time.sleep(60) between purge + assertionSlow; sometimes shorter / sometimes longerPoll cf-cache-status until MISS (timeout 30s)
Test purge against prodPollutes prod cache; rate-limitedDedicated test domain + zone
Purge-everything for smoke testMassive cache flush; alarming for opsSingle-file or tag-based
Don't test multi-regionEdge in test region; user in another sees staleVerify across regions
No Cache-Tag / Surrogate-Key on originGroup-purge has nothing to targetOrigin must set tags
Use only cf-cache-status to verify freshCould be HIT of newly-purged fresh fetchCompare response body to known fresh state
Skip purge-key naming reviewHot keys (all, feed) become noisyPer-resource tagging strategy
Assume purge is synchronousCloudflare: <30s; CloudFront: minutesPlan for async; poll

Limitations

  • Per-vendor behaviour differs. Cloudflare and Fastly purge in seconds; CloudFront invalidations take longer. Test against the actual vendor in use.
  • Purge rate limits. Cloudflare has per-zone tag-purge limits; Fastly has API-call rate limits. Tests can hit these.
  • Edge node consistency. Even within a CDN, edge nodes refresh at slightly different times. Cross-edge tests need tolerance.
  • Doesn't test the origin's behaviour. If origin sends uncacheable responses, purge does nothing. Pair with origin Cache-Control assertions in the project's HTTP-level runner and the browser-tier tests in references/browser-cache-control.md.

References

Client-tier (browser) Cache-Control tests

View source (opens in new window)

Client-tier (browser) Cache-Control tests

Browser cache tests verify the request side of caching: does the browser actually respect the Cache-Control headers the server sends? Per MDN Cache-Control (opens in new window) the directive set is identical to RFC 9111 (opens in new window), but runtime behaviour differs subtly between Chromium, Firefox, and Safari. Scope: behaviour a browser decides (served-from-cache, revalidation, SW strategy, reload semantics). Asserting only which header the server emits needs no browser - do that in the project's existing HTTP-level runner (supertest, requests, RestAssured, curl -I).

Workflow

  1. Pick the behaviour to assert: response Cache-Control header, ETag 304 round-trip, service-worker strategy, or reload semantics.
  2. Scaffold a Playwright spec; attach page.on('response') before page.goto.
  3. Read resp.headers()['cache-control'] and assert with a regex toMatch, never an exact string (vendors append directives).
  4. For served-from-cache proof, use CDP Network.responseReceived.response.fromDiskCache / fromMemoryCache.
  5. For revalidation, reload after the TTL and assert the second response is 304 with a matching If-None-Match.
  6. For service-worker strategies, populate the cache online, then context.setOffline(true) and reload.
  7. Run npx playwright test across the Chromium / Firefox / WebKit matrix.

Worked example - hashed bundle + uncached API in one spec

import { test, expect } from '@playwright/test';

test('bundle immutable, /api/me uncached', async ({ page }) => {
  const seen: Record<string, string> = {};
  page.on('response', (resp) => {
    const cc = resp.headers()['cache-control'] ?? '';
    if (resp.url().match(/\.\w+\.js$/)) seen.bundle = cc;
    if (resp.url().endsWith('/api/me')) seen.api = cc;
  });
  await page.goto('https://example.com/dashboard');

  expect(seen.bundle).toMatch(/max-age=\d{6,}/);   // ~10+ days
  expect(seen.bundle).toContain('immutable');      // per RFC 8246
  expect(seen.api).toMatch(/(no-store|private)/);
});

The bundle assertion fails if the build drops immutable (silent perf regression); the /api/me assertion catches a proxy adding a public max-age - a leak of per-user data into shared caches.

The deeper recipes - served-from-cache detection via CDP, ETag revalidation round-trips, hard-reload semantics, and service-worker (Workbox) strategies - are in playwright-cache-recipes.md (opens in new window).

Useful response-event surface

MethodReturns
resp.status()HTTP status
resp.headers()All response headers
resp.fromServiceWorker()Whether a SW intercepted
resp.request().headers()Request headers (If-None-Match)
resp.timing()Cached fetches have minimal responseEnd - responseStart

Anti-patterns

Anti-patternWhy it failsFix
status() == 200 to "prove" a cache miss304 is also cache-relatedInspect headers / fromDiskCache
Fresh browser context per testCache starts empty; no "second load"Reuse the context within a test
Exact-string cache-control assertionsVendor directives break itRegex toMatch
Chromium-only runsSafari + Firefox differ (SW, ITP)Run the matrix in CI
No 304 testETag round-trip drift unnoticedTest the second-load 304 path
Mocking caches.match()Bypasses the real storage layerReal Cache API + Playwright

Limitations

  • Playwright network events don't always expose fromDiskCache; some assertions need raw CDP.
  • Tests run with fresh profiles - long-term eviction behaviour under storage pressure isn't exercisable.
  • Service-worker registration is async; wait for navigator.serviceWorker.ready before asserting.

References

Playwright cache recipes

View source (opens in new window)

Playwright cache recipes

Deeper browser-cache authoring recipes for browser-cache-control.md (opens in new window). The basic Cache-Control header assertion lives there; these cover served-from-cache detection, ETag revalidation, hard reload, and service workers.

Verify second-load is from cache

Playwright doesn't expose "from disk cache" directly, but request timing reveals it:

test('static asset second load is from disk cache', async ({ page }) => {
  await page.goto('https://example.com');           // first load (network)
  const responses: Array<{ url: string; fromCache: boolean }> = [];
  page.on('response', (resp) => {
    responses.push({
      url: resp.url(),
      fromCache: resp.fromServiceWorker() || resp.request().redirectedFrom() !== null,
    });
  });
  await page.reload();
  // Playwright doesn't expose 'from disk cache' directly, but
  // request timing reveals it:
  const asset = responses.find((r) => r.url.endsWith('.js'));
  // The Network panel `(disk cache)` annotation comes from
  // timing.responseEnd === timing.responseStart for cached items.
});

For a stronger check, use Chrome DevTools Protocol via Playwright:

const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
cdp.on('Network.responseReceived', (params) => {
  if (params.response.url.endsWith('.js')) {
    expect(params.response.fromDiskCache).toBe(true);
  }
});
await page.reload();

ETag revalidation round-trip

Per RFC 9111 §4.3.1:

test('ETag triggers 304 on revalidation', async ({ page }) => {
  let firstEtag: string | undefined;
  page.on('response', (resp) => {
    if (resp.url() === 'https://example.com/api/feed') {
      const etag = resp.headers()['etag'];
      if (resp.status() === 200 && !firstEtag) firstEtag = etag;
      else if (firstEtag) {
        expect(resp.request().headers()['if-none-match']).toBe(firstEtag);
        expect(resp.status()).toBe(304);
      }
    }
  });

  // First load
  await page.goto('https://example.com/dashboard');
  // Reload after TTL - browser should send If-None-Match
  await page.waitForTimeout(2000);
  await page.reload();
});

Hard reload (Cmd+Shift+R) semantics

Browsers send Cache-Control: no-cache on hard reload, bypassing the cache. Test:

test('hard reload bypasses cache', async ({ page }) => {
  await page.goto('https://example.com');

  page.on('request', (req) => {
    if (req.url().endsWith('.js')) {
      expect(req.headers()['cache-control']).toMatch(/no-cache/);
    }
  });

  // Playwright doesn't have a direct "hard reload"; simulate via CDP:
  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Page.reload', { ignoreCache: true });
});

Service Worker / Workbox

Workbox provides standard strategies; test which is used:

test('offline page uses cache-first strategy', async ({ context, page }) => {
  // Go online, populate cache
  await page.goto('https://example.com');
  // Go offline
  await context.setOffline(true);
  // Reload - should still work
  await page.reload();
  await expect(page.locator('h1')).toHaveText('Example');
});

test('api uses network-first with fallback', async ({ context, page }) => {
  await page.goto('https://example.com/api-status');
  await context.setOffline(true);
  await page.reload();
  // Stale cached response shown
  await expect(page.locator('.api-status')).toBeVisible();
});

Related skills

cache-coherence-patterns-reference

Pure-reference catalog of cache-coherence patterns across the request path. Defines the five-tier cache stack (browser → CDN → reverse-proxy → application → data store), the per-tier cache-writing patterns (cache-aside, write-through, write-back, write-around, refresh-ahead), and the canonical invalidation strategies (TTL-only, event-driven purge, surrogate keys, version-tagged URLs, soft purge), plus an anti-pattern table and a worked multi-tenant coherence-test example. Deep detail lives in references/: RFC 9111 Cache-Control / Vary / ETag directive tables, the cross-tier test surface, cache-stampede (thundering-herd) mitigations incl. the XFetch formula, and RFC 5861 stale-while-revalidate / stale-if-error semantics. Use for pattern selection, Cache-Control header design, coherence audits, stampede-refresh strategy, and SWR/SIE window design; use a cache-key-collision check when the question is whether two concrete requests collide on a key scheme.

cache-key-discriminator-audit

Audits whether a cache key carries every discriminator the cached response actually depends on, so two requests that must not share a slot cannot collide. Ranks identity discriminators (tenant, user, authorization scope, plan tier) above presentation ones (locale, region, currency, feature flag), maps each missing discriminator to the data-exposure or wrong-content consequence it causes, classifies each key-and-value pair into a critical / high / medium severity band (including the Python lru_cache-on-an-instance-method trap), and writes the fix as a namespaced key builder plus the matching HTTP Vary header. Use when a cache key is being designed or changed, when a per-user or per-tenant response is about to be stored in a shared cache or CDN, or when investigating a report that one user or tenant saw another's data.

redis-cache-tests

Wraps Redis cache testing: EXPIRE / PEXPIRE / TTL verification (Redis 7+ NX/XX/GT/LT flags), cache-aside write-then-invalidate (write source → DEL key → assert fresh read), eviction under memory pressure (maxmemory + allkeys-lru), pub/sub invalidation across nodes, and tenant key-namespacing. Use when Redis is the app's primary cache. For the CDN/browser HTTP tier use cdn-cache-purge-tests; a runnable test, not the cache-coherence-patterns-reference catalog (which owns the stampede + stale-while-revalidate references).

varnish-test-vtc-syntax

Wraps the varnishtest CLI + VTC (Varnish Test Case) syntax for testing VCL configurations. Covers the VTC test-file format (varnishtest scripts with server { ... } + client { ... } + varnish v1 -vcl+backend { ... } blocks), the grace-mode + saint-mode behaviours (stale-while-revalidate + stale-if-error equivalents in VCL), the PURGE method handler pattern (vcl_purge subroutine + ACL guards), and surrogate-key invalidation via xkey vmod. Use when authoring or testing Varnish-based caching layers.