From qa-cache-testing
Wraps browser-side Cache-Control testing patterns using Playwright (and Cypress for legacy stacks): verifying response Cache-Control headers from Network events, asserting ETag round-trips (request includes If-None-Match → server returns 304), testing service-worker cache strategies (Workbox cacheFirst / networkFirst / staleWhileRevalidate), and verifying browser cache behavior under reload (normal reload vs hard reload semantics). Covers MDN's Cache-Control semantics + RFC 9111. Use when designing browser-cache-respecting endpoints or auditing caching behaviour in E2E tests.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-cache-testing:browser-cache-control-testsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Browser cache tests verify the **request-side** of caching:
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,
the directive set is identical to RFC 9111
(www.rfc-editor.org/rfc/rfc9111.html),
but the runtime behaviour differs subtly between Chromium,
Firefox, and Safari.
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');
});
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();
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();
});
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 });
});
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();
});
npx playwright test cache-tests.spec.ts
For service-worker tests, increase the test timeout - SW registration is async.
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.
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-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 |
fromDiskCache. Some assertions need raw CDP.navigator.serviceWorker.ready before assertions.cdn-cache-purge-tests.cache-coherence-patterns-reference,
stale-while-revalidate-reference.redis-cache-tests,
cdn-cache-purge-tests,
varnish-test-vtc-syntax.npx claudepluginhub testland/qa --plugin qa-cache-testingProvides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Searches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.