Identify stale cache issues across browser cache, CDN layers, API response caching, and application-level caches that cause users to see outdated content
You are an expert QA automation engineer specializing in cache correctness testing. When the user asks you to write, review, or debug tests for stale cache issues, follow these detailed instructions to identify caching defects across browser caches, CDN layers, API response caches, service workers, and application-level caching systems.
Cache-Control, ETag, Last-Modified, Age, and X-Cache headers in responses.Organize stale cache testing projects with this structure:
tests/
cache/
headers/
cache-control.spec.ts
etag-validation.spec.ts
last-modified.spec.ts
vary-header.spec.ts
cdn/
cdn-invalidation.spec.ts
edge-cache.spec.ts
purge-verification.spec.ts
service-worker/
sw-cache-audit.spec.ts
sw-update-flow.spec.ts
api-cache/
response-cache.spec.ts
stale-while-revalidate.spec.ts
browser/
disk-cache.spec.ts
memory-cache.spec.ts
storage-cache.spec.ts
post-deployment/
asset-version.spec.ts
cache-busting.spec.ts
helpers/
cache-header-parser.ts
cdn-client.ts
cache-inspector.ts
fixtures/
cache-test.fixture.ts
playwright.config.ts
Cache headers are the foundation of caching behavior. Incorrect headers cause all downstream cache layers to behave incorrectly.
import { test, expect } from '@playwright/test';
interface CacheExpectation {
urlPattern: string | RegExp;
expectedDirectives: string[];
forbiddenDirectives?: string[];
maxAgeRange?: { min: number; max: number };
description: string;
}
const CACHE_EXPECTATIONS: CacheExpectation[] = [
{
urlPattern: /\.(js|css)(\?.*)?$/,
expectedDirectives: ['public', 'max-age', 'immutable'],
maxAgeRange: { min: 2592000, max: 31536000 }, // 30 days to 1 year
description: 'Static assets should be cached long-term with immutable',
},
{
urlPattern: /\.(png|jpg|jpeg|gif|svg|webp|avif)(\?.*)?$/,
expectedDirectives: ['public', 'max-age'],
maxAgeRange: { min: 86400, max: 31536000 }, // 1 day to 1 year
description: 'Images should be cached with public directive',
},
{
urlPattern: /\/api\//,
expectedDirectives: ['no-store'],
forbiddenDirectives: ['public'],
description: 'API responses should not be cached by default',
},
{
urlPattern: /\.html$/,
expectedDirectives: ['no-cache'],
forbiddenDirectives: ['immutable'],
description: 'HTML pages should revalidate on every request',
},
{
urlPattern: /\/api\/public\//,
expectedDirectives: ['public', 's-maxage'],
maxAgeRange: { min: 60, max: 3600 }, // 1 min to 1 hour
description: 'Public API endpoints should use s-maxage for CDN caching',
},
];
function parseCacheControl(header: string): Map<string, string | boolean> {
const directives = new Map<string, string | boolean>();
header.split(',').forEach((part) => {
const trimmed = part.trim();
const [key, value] = trimmed.split('=');
directives.set(key.trim(), value ? value.trim() : true);
});
return directives;
}
test.describe('Cache-Control Header Validation', () => {
test('all responses should have correct Cache-Control headers', async ({ page }) => {
const violations: string[] = [];
page.on('response', (response) => {
const url = response.url();
const cacheControl = response.headers()['cache-control'];
for (const expectation of CACHE_EXPECTATIONS) {
const matches =
typeof expectation.urlPattern === 'string'
? url.includes(expectation.urlPattern)
: expectation.urlPattern.test(url);
if (!matches) continue;
if (!cacheControl) {
violations.push(
`Missing Cache-Control for ${url} (${expectation.description})`
);
continue;
}
const directives = parseCacheControl(cacheControl);
for (const required of expectation.expectedDirectives) {
if (required === 'max-age') {
if (!directives.has('max-age') && !directives.has('s-maxage')) {
violations.push(
`${url}: missing max-age directive (${expectation.description})`
);
}
} else if (!directives.has(required)) {
violations.push(
`${url}: missing "${required}" directive (${expectation.description})`
);
}
}
if (expectation.forbiddenDirectives) {
for (const forbidden of expectation.forbiddenDirectives) {
if (directives.has(forbidden)) {
violations.push(
`${url}: has forbidden "${forbidden}" directive (${expectation.description})`
);
}
}
}
if (expectation.maxAgeRange) {
const maxAge = parseInt(
(directives.get('max-age') || directives.get('s-maxage') || '0') as string,
10
);
if (maxAge < expectation.maxAgeRange.min || maxAge > expectation.maxAgeRange.max) {
violations.push(
`${url}: max-age=${maxAge} outside expected range [${expectation.maxAgeRange.min}, ${expectation.maxAgeRange.max}]`
);
}
}
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Navigate to a few key pages to capture more responses
const routes = ['/dashboard', '/settings', '/about'];
for (const route of routes) {
await page.goto(route);
await page.waitForLoadState('networkidle');
}
if (violations.length > 0) {
console.log('Cache-Control violations:');
violations.forEach((v) => console.log(` - ${v}`));
}
expect(violations).toHaveLength(0);
});
});
import { test, expect } from '@playwright/test';
test.describe('ETag Validation', () => {
test('API responses should include ETag headers', async ({ request }) => {
const response = await request.get('/api/public/skills');
const etag = response.headers()['etag'];
expect(etag, 'API response missing ETag header').toBeDefined();
// Verify conditional request works
const conditionalResponse = await request.get('/api/public/skills', {
headers: { 'If-None-Match': etag },
});
expect(conditionalResponse.status()).toBe(304);
});
test('ETag should change when content changes', async ({ request }) => {
// First request to get initial ETag
const response1 = await request.get('/api/public/skills');
const etag1 = response1.headers()['etag'];
// Modify data (via API or direct DB mutation)
await request.post('/api/skills', {
data: {
name: 'Test Skill',
description: 'A test skill for cache validation that verifies ETags change properly',
version: '1.0.0',
},
});
// Second request should have a different ETag
const response2 = await request.get('/api/public/skills');
const etag2 = response2.headers()['etag'];
expect(etag2).not.toBe(etag1);
});
test('Last-Modified should be present and accurate', async ({ request }) => {
const response = await request.get('/api/public/skills/1');
const lastModified = response.headers()['last-modified'];
expect(lastModified, 'Missing Last-Modified header').toBeDefined();
const lastModifiedDate = new Date(lastModified);
expect(lastModifiedDate.getTime()).not.toBeNaN();
// Verify conditional request with If-Modified-Since
const conditionalResponse = await request.get('/api/public/skills/1', {
headers: { 'If-Modified-Since': lastModified },
});
expect(conditionalResponse.status()).toBe(304);
});
});
import { test, expect } from '@playwright/test';
test.describe('Vary Header Verification', () => {
test('API responses should include appropriate Vary headers', async ({ request }) => {
const response = await request.get('/api/public/skills');
const vary = response.headers()['vary'];
expect(vary, 'Missing Vary header on API response').toBeDefined();
// API should vary on Accept and Accept-Encoding at minimum
const varyParts = vary.split(',').map((v: string) => v.trim().toLowerCase());
expect(varyParts).toContain('accept');
expect(varyParts).toContain('accept-encoding');
});
test('locale-dependent responses should Vary on Accept-Language', async ({ request }) => {
const response = await request.get('/api/public/content', {
headers: { 'Accept-Language': 'en-US' },
});
const vary = response.headers()['vary'];
expect(vary).toBeDefined();
const varyParts = vary.split(',').map((v: string) => v.trim().toLowerCase());
expect(varyParts).toContain('accept-language');
});
test('auth-dependent responses should Vary on Authorization', async ({ request }) => {
const response = await request.get('/api/dashboard');
const vary = response.headers()['vary'];
expect(vary).toBeDefined();
const varyParts = vary.split(',').map((v: string) => v.trim().toLowerCase());
expect(varyParts).toContain('authorization');
});
});
CDN caches add a layer of complexity because they cache at the edge, geographically distributed from the origin.
import { test, expect } from '@playwright/test';
interface CDNCacheResult {
url: string;
cacheStatus: string; // HIT, MISS, STALE, BYPASS
age: number;
edgeLocation?: string;
}
async function checkCDNCacheStatus(
url: string,
headers?: Record<string, string>
): Promise<CDNCacheResult> {
const response = await fetch(url, { headers });
// Common CDN cache status headers
const cacheStatus =
response.headers.get('x-cache') ||
response.headers.get('cf-cache-status') || // Cloudflare
response.headers.get('x-vercel-cache') || // Vercel
response.headers.get('x-cdn-cache-status') ||
response.headers.get('x-fastly-cache-status') || // Fastly
'UNKNOWN';
const age = parseInt(response.headers.get('age') || '0', 10);
const edgeLocation =
response.headers.get('x-served-by') ||
response.headers.get('cf-ray') ||
response.headers.get('x-vercel-id');
return {
url,
cacheStatus: cacheStatus.toUpperCase(),
age,
edgeLocation: edgeLocation || undefined,
};
}
test.describe('CDN Cache Testing', () => {
test('static assets should be served from CDN cache', async () => {
const staticAssets = [
'/assets/main.js',
'/assets/styles.css',
'/images/logo.svg',
];
for (const asset of staticAssets) {
const baseUrl = process.env.BASE_URL || 'https://example.com';
// First request may be a MISS
await checkCDNCacheStatus(`${baseUrl}${asset}`);
// Second request should be a HIT
const result = await checkCDNCacheStatus(`${baseUrl}${asset}`);
expect(
['HIT', 'STALE'].includes(result.cacheStatus),
`${asset}: expected CDN cache HIT but got ${result.cacheStatus}`
).toBe(true);
}
});
test('CDN should respect s-maxage for API responses', async () => {
const baseUrl = process.env.BASE_URL || 'https://example.com';
const url = `${baseUrl}/api/public/skills`;
const response = await fetch(url);
const cacheControl = response.headers.get('cache-control') || '';
// Verify s-maxage is present for CDN-cached APIs
expect(cacheControl).toContain('s-maxage');
const sMaxAge = parseInt(
cacheControl.match(/s-maxage=(\d+)/)?.[1] || '0',
10
);
expect(sMaxAge).toBeGreaterThan(0);
});
test('CDN should purge cache after content update', async ({ request }) => {
const baseUrl = process.env.BASE_URL || 'https://example.com';
// Get initial content
const before = await fetch(`${baseUrl}/api/public/skills/test-skill`);
const beforeBody = await before.json();
const beforeEtag = before.headers.get('etag');
// Update the content
await request.patch('/api/skills/test-skill', {
data: { description: `Updated at ${Date.now()}` },
});
// Allow time for cache invalidation propagation
await new Promise((resolve) => setTimeout(resolve, 5000));
// Verify CDN serves the updated content
const after = await fetch(`${baseUrl}/api/public/skills/test-skill`);
const afterBody = await after.json();
const afterEtag = after.headers.get('etag');
expect(afterBody.description).not.toBe(beforeBody.description);
expect(afterEtag).not.toBe(beforeEtag);
});
});
Service workers intercept network requests and can serve stale content indefinitely if not managed correctly.
import { test, expect } from '@playwright/test';
test.describe('Service Worker Cache Auditing', () => {
test('service worker should not cache API responses', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check what the service worker has cached
const cachedUrls = await page.evaluate(async () => {
const cacheNames = await caches.keys();
const allUrls: string[] = [];
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
allUrls.push(...keys.map((k) => k.url));
}
return allUrls;
});
// API responses should NOT be in the service worker cache
const cachedApiUrls = cachedUrls.filter((url) => url.includes('/api/'));
expect(
cachedApiUrls,
`Service worker is caching API responses: ${cachedApiUrls.join(', ')}`
).toHaveLength(0);
});
test('service worker should update cached assets on new deployment', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Get the current service worker version
const swVersion = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration?.active) return null;
// Most SWs expose a version via a custom message
return new Promise<string | null>((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => resolve(event.data.version);
registration.active!.postMessage({ type: 'GET_VERSION' }, [channel.port2]);
setTimeout(() => resolve(null), 2000);
});
});
// Trigger a service worker update check
const updateFound = await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) return false;
await registration.update();
return registration.waiting !== null || registration.installing !== null;
});
// If an update is available, verify it activates
if (updateFound) {
// Wait for the new service worker to activate
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (registration?.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
await page.waitForTimeout(2000);
// Verify the old caches are cleaned up
const remainingCaches = await page.evaluate(async () => {
return await caches.keys();
});
// Should not have old versioned caches lingering
const oldCaches = remainingCaches.filter((name) =>
name.includes('v1') || name.includes('old')
);
expect(oldCaches).toHaveLength(0);
}
});
test('service worker should serve fresh content after skip-waiting', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Get content before any update
const contentBefore = await page.locator('h1').first().textContent();
// Force service worker update and activation
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
await registration.update();
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
}
});
// Reload and verify content is fresh
await page.reload();
await page.waitForLoadState('networkidle');
const contentAfter = await page.locator('h1').first().textContent();
// Content should at minimum be non-empty (not a broken cache response)
expect(contentAfter).toBeTruthy();
expect(contentAfter!.length).toBeGreaterThan(0);
});
});
The stale-while-revalidate directive allows serving stale content while fetching fresh content in the background. Testing this behavior requires timing-aware assertions.
import { test, expect } from '@playwright/test';
test.describe('Stale-While-Revalidate Behavior', () => {
test('SWR responses should eventually serve fresh content', async ({ request }) => {
// First request -- populates the cache
const response1 = await request.get('/api/public/feed');
expect(response1.ok()).toBe(true);
const body1 = await response1.json();
// Wait for the max-age to expire but within SWR window
// Assuming max-age=60, stale-while-revalidate=300
const cacheControl = response1.headers()['cache-control'];
const maxAge = parseInt(cacheControl.match(/max-age=(\d+)/)?.[1] || '60', 10);
// In testing, we simulate passage of time by waiting slightly longer than max-age
// For a real test, you might use a test server that controls time
await new Promise((resolve) => setTimeout(resolve, (maxAge + 1) * 1000));
// Second request -- should get stale content but trigger revalidation
const response2 = await request.get('/api/public/feed');
const age2 = parseInt(response2.headers()['age'] || '0', 10);
// The response might be stale (age > max-age)
if (age2 > maxAge) {
// This is the SWR behavior -- stale content served immediately
// Wait for background revalidation to complete
await new Promise((resolve) => setTimeout(resolve, 2000));
// Third request should now have fresh content
const response3 = await request.get('/api/public/feed');
const age3 = parseInt(response3.headers()['age'] || '0', 10);
expect(age3).toBeLessThan(maxAge);
}
});
test('SWR should not serve content beyond stale-while-revalidate window', async ({
request,
}) => {
const response = await request.get('/api/public/feed');
const cacheControl = response.headers()['cache-control'] || '';
if (cacheControl.includes('stale-while-revalidate')) {
const swrWindow = parseInt(
cacheControl.match(/stale-while-revalidate=(\d+)/)?.[1] || '0',
10
);
// Verify the SWR window is reasonable
expect(swrWindow).toBeGreaterThan(0);
expect(swrWindow).toBeLessThanOrEqual(86400); // Max 1 day
}
});
});
Cache key collisions happen when different content is cached under the same key, causing one user to see another user's data.
import { test, expect } from '@playwright/test';
test.describe('Cache Key Collision Detection', () => {
test('authenticated endpoints should not share cached responses', async ({ request }) => {
// Request as User A
const responseA = await request.get('/api/dashboard', {
headers: { Authorization: 'Bearer token-user-a' },
});
const dataA = await responseA.json();
// Request as User B
const responseB = await request.get('/api/dashboard', {
headers: { Authorization: 'Bearer token-user-b' },
});
const dataB = await responseB.json();
// These should contain different user-specific data
expect(dataA.userId).not.toBe(dataB.userId);
// Verify that User B did not receive User A's cached response
expect(dataB.userId).toBe('user-b');
});
test('query parameter variations should produce distinct cache entries', async ({
request,
}) => {
const response1 = await request.get('/api/public/skills?page=1&sort=newest');
const body1 = await response1.json();
const response2 = await request.get('/api/public/skills?page=2&sort=newest');
const body2 = await response2.json();
const response3 = await request.get('/api/public/skills?page=1&sort=popular');
const body3 = await response3.json();
// Each variation should return different content
expect(JSON.stringify(body1)).not.toBe(JSON.stringify(body2));
expect(JSON.stringify(body1)).not.toBe(JSON.stringify(body3));
});
test('locale-specific responses should not collide', async ({ request }) => {
const enResponse = await request.get('/api/public/content', {
headers: { 'Accept-Language': 'en-US' },
});
const enBody = await enResponse.json();
const deResponse = await request.get('/api/public/content', {
headers: { 'Accept-Language': 'de-DE' },
});
const deBody = await deResponse.json();
// Content should differ by locale
expect(enBody.locale).not.toBe(deBody.locale);
});
});
Deployments are the most common trigger for stale cache issues. Old JavaScript bundles may reference old API contracts, causing runtime errors.
import { test, expect } from '@playwright/test';
test.describe('Post-Deployment Cache Busting', () => {
test('JavaScript bundles should have content-hashed filenames', async ({ page }) => {
const scriptUrls: string[] = [];
page.on('response', (response) => {
if (response.url().endsWith('.js')) {
scriptUrls.push(response.url());
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
for (const url of scriptUrls) {
// Content-hashed filenames typica
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/Stale Cache Finder下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:cache-testing, stale-cache, cdn, cache-invalidation, cache-busting, etag, cache-headers, browser-cache