Testland
Browse all skills & agents

browser-cache-control-tests

Wraps browser-side Cache-Control testing with Playwright (Cypress for legacy stacks): asserting response Cache-Control headers from Network events, ETag round-trips (If-None-Match → 304), service-worker strategies (Workbox cacheFirst / networkFirst / staleWhileRevalidate), and reload semantics (normal vs hard). Covers MDN Cache-Control + RFC 9111. Use when auditing browser-tier caching in E2E tests. For CDN-edge purge use cdn-cache-purge-tests; for the reverse-proxy tier use varnish-test-vtc-syntax; for an app-tier store use redis-cache-tests; SWR directive semantics live in stale-while-revalidate-reference.

Install with skills.sh (any agent)

npx skills add testland/qa --skill browser-cache-control-tests
View source

browser-cache-control-tests

Overview

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 (www.rfc-editor.org/rfc/rfc9111.html (opens in new window)), but the runtime behaviour differs subtly between Chromium, Firefox, and Safari.

When to use

  • Verifying a new endpoint's Cache-Control is interpreted as intended.
  • Service-worker test patterns (Workbox).
  • Tests for revalidation behaviour (ETag round-trip).
  • Investigating "this page caches forever" or "cache never hits" complaints.

How to use

  1. Pick the caching behaviour to assert: response Cache-Control header, ETag 304 round-trip, service-worker strategy, or reload semantics.
  2. Scaffold a Playwright spec and attach a page.on('response') (or page.on('request')) listener before page.goto.
  3. Read the header via resp.headers()['cache-control'] and assert with a regex toMatch, never an exact string.
  4. For served-from-cache proof, open a CDP session and read 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 in CI.

Authoring

Playwright network interception

Playwright can inspect every request + response, including served-from-cache state.

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

test('static assets have long Cache-Control', async ({ page }) => {
  page.on('response', (resp) => {
    if (resp.url().endsWith('.js')) {
      const cc = resp.headers()['cache-control'];
      expect(cc).toMatch(/max-age=\d{6,}/);  // ≥ ~10 days
      expect(cc).toContain('immutable');     // per RFC 8246
    }
  });
  await page.goto('https://example.com');
});

test('API responses are not cached by default', async ({ page }) => {
  page.on('response', (resp) => {
    if (resp.url().includes('/api/')) {
      const cc = resp.headers()['cache-control'];
      expect(cc).toMatch(/(no-store|max-age=0|private)/);
    }
  });
  await page.goto('https://example.com/dashboard');
});

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

Worked example

A release ships hashed bundles (app.4f2a.js) that should cache for a year, plus a /api/me endpoint that must never be cached. One spec audits both:

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');
  expect(seen.api).toMatch(/(no-store|private)/);
});

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

Running

npx playwright test cache-tests.spec.ts

For service-worker tests, increase the test timeout - SW registration is async.

Parsing results

Playwright's response event gives access to:

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

For the canonical "served from cache" assertion, fall back to CDP Network.responseReceived.response.fromDiskCache or fromMemoryCache.

CI integration

jobs:
  browser-cache-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci && npx playwright install --with-deps chromium
      - run: npx playwright test tests/cache/

Anti-patterns

Anti-patternWhy it failsFix
Asserting on response.status() == 200 to "prove" cache miss304 is also cache-related; misses revalidation casesInspect headers / fromDiskCache
Per-test fresh browser contextCache starts empty; can't test "second load" patternReuse context within a test
Asserting on cache-control matches exact stringServer adds vendor-specific directives; brittleUse regex toMatch
Testing only ChromiumSafari + Firefox have differences (Service Worker, ITP)Run matrix in CI
Skipping immutable test for hashed assetsBrowsers re-validate; perf regression silentPer RFC 8246, hashed asset URLs should be immutable
No 304 testETag round-trip drift unnoticedTest the second-load 304 path
Mocking caches.match()Bypasses the actual storage layerUse real Cache API + Playwright
Hard reload behaviour assumed cross-browserSafari hard-reload differs from ChromeTest the actual target browsers

Limitations

  • Playwright's network events don't always include fromDiskCache. Some assertions need raw CDP.
  • Browser cache implementation varies. Edge cases (e.g., HTTPS+private mode, ITP) may differ from documented behaviour.
  • Tests run with fresh profiles. Long-term browser-cache behaviour (eviction under storage pressure) isn't exercisable.
  • Service Worker registration is async. Tests must wait for navigator.serviceWorker.ready before assertions.
  • Doesn't test the CDN tier. Pair with cdn-cache-purge-tests.

References

Playwright cache recipes

View source (opens in new window)

Playwright cache recipes

Deeper browser-cache authoring recipes for browser-cache-control-tests. The basic Cache-Control header assertion lives inline in SKILL.md; 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 - the RFC 9111 Cache-Control / Vary / ETag directive tables and the cross-tier coherence + per-tier test surface - lives in references/. Use for pattern selection, Cache-Control header design, and coherence audits; use a cache-key-collision check when the question is whether two requests in an existing system collide on a concrete key scheme. Consumed by redis-cache-tests, cdn-cache-purge-tests, varnish-test-vtc-syntax, browser-cache-control-tests, and the cache-key-collision check.

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.

cache-stampede-reference

Pure-reference catalog of cache-stampede (thundering-herd) phenomena and mitigations: parallel misses on key expiry trigger simultaneous recomputation (often congestion-collapse), countered by three families - locking, external recomputation near-expiry, and probabilistic early expiration via XFetch (`(time() - delta * beta * log(rand(0,1))) >= expiry`). Use when designing cache-refresh strategy or diagnosing a stampede incident. This is the single failure-mode pattern; for the broader multi-tier pattern catalog use cache-coherence-patterns-reference, for the stale-while-revalidate / stale-if-error extensions use stale-while-revalidate-reference; a reference consumed by redis-cache-tests, not a runnable test.

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). Use when designing or auditing CDN cache-invalidation workflows.

memcached-tests

Wraps Memcached cache testing against a real container: an inline set/get/expire/no-persistence worked example, with the exhaustive protocol-command tests (set/get/add/cas/incr/decr, TTL 0=never-expire / 30-day Unix-timestamp boundary) and the LRU-eviction, consistent-hashing distribution, ElastiCache Auto Discovery, and CI-wiring deep-dives in references/. Use when writing tests for an application that uses Memcached as its primary cache, when verifying ElastiCache Memcached cluster behaviour, or when contrasting Memcached eviction and distribution semantics against Redis.

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 a Memcached app-tier store use memcached-tests; for the browser/HTTP tier use browser-cache-control-tests; a runnable test, not the cache-stampede-reference or cache-coherence-patterns-reference catalogs.

stale-while-revalidate-reference

Pure-reference catalog of RFC 5861's stale-while-revalidate + stale-if-error Cache-Control extensions. Defines stale-while-revalidate=N (caches MAY serve a stale response while asynchronously revalidating, up to N seconds after expiry) and stale-if-error=N (caches MAY serve stale on 5xx upstream errors). Distinguishes from RFC 9111's must-revalidate (forbids serving stale) and from manual cache-aside refresh (synchronous). Covers the interaction with the freshness lifetime (max-age) and the cache-stampede-mitigation properties. Use when designing the cache-refresh boundary or auditing existing Cache-Control headers.

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.