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-testsbrowser-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
How to use
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.tsFor service-worker tests, increase the test timeout - SW registration is async.
Parsing results
Playwright's response event gives access to:
| Method | Returns |
|---|---|
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-pattern | Why it fails | Fix |
|---|---|---|
| Asserting on response.status() == 200 to "prove" cache miss | 304 is also cache-related; misses revalidation cases | Inspect headers / fromDiskCache |
| Per-test fresh browser context | Cache starts empty; can't test "second load" pattern | Reuse context within a test |
Asserting on cache-control matches exact string | Server adds vendor-specific directives; brittle | Use regex toMatch |
| Testing only Chromium | Safari + Firefox have differences (Service Worker, ITP) | Run matrix in CI |
Skipping immutable test for hashed assets | Browsers re-validate; perf regression silent | Per RFC 8246, hashed asset URLs should be immutable |
| No 304 test | ETag round-trip drift unnoticed | Test the second-load 304 path |
Mocking caches.match() | Bypasses the actual storage layer | Use real Cache API + Playwright |
| Hard reload behaviour assumed cross-browser | Safari hard-reload differs from Chrome | Test the actual target browsers |
Limitations
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.