Testing CDN Caching Rules and Cache Invalidation: A Developer's Guide
Phil Karlton's famous observation — "there are only two hard things in Computer Science: cache invalidation and naming things" — rings especially true for production CDN deployments. The harder truth is that CDN cache bugs are uniquely difficult to catch: they behave differently between staging and production, they depend on network topology, and they often surface as intermittent issues rather than reproducible failures.
This guide focuses specifically on testing your CDN behavior: writing assertions against cache headers, validating invalidation logic, and building CI checks that protect you from cache-related regressions.
The Cache-Control Header Anatomy
Every caching decision flows from HTTP response headers. Before testing, ensure you understand what your application is actually sending:
// tests/cache/headers.test.ts
import { test, expect } from '@playwright/test';
test.describe('Cache-Control headers by route type', () => {
test('static assets are cached long-term', async ({ request }) => {
const response = await request.get('/_next/static/chunks/main.js');
const cacheControl = response.headers()['cache-control'];
// Static assets should have immutable, long max-age
expect(cacheControl).toContain('max-age=31536000');
expect(cacheControl).toContain('immutable');
expect(cacheControl).not.toContain('no-store');
});
test('API responses with user data are not cached', async ({ request }) => {
const response = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
});
const cacheControl = response.headers()['cache-control'];
expect(cacheControl).toMatch(/private|no-store/);
});
test('public product pages have appropriate shared caching', async ({ request }) => {
const response = await request.get('/products/sample-product');
const cacheControl = response.headers()['cache-control'];
// Should be cacheable at CDN, but not forever
expect(cacheControl).toContain('public');
expect(cacheControl).toMatch(/s-maxage=\d+/); // CDN cache TTL
expect(cacheControl).toContain('stale-while-revalidate');
});
});
The CDN Caching Matrix
| Content Type | Cache-Control Pattern |
Rationale |
|---|---|---|
/_next/static/** |
public, max-age=31536000, immutable |
Content-hashed filenames, safe to cache forever |
/images/** (CDN-processed) |
public, max-age=86400, s-maxage=604800 |
Processed images rarely change |
| Marketing pages | public, s-maxage=300, stale-while-revalidate=60 |
CDN caches, revalidates in background |
| Product pages | public, s-maxage=60, stale-while-revalidate=30 |
Short TTL, freshness matters |
Auth pages (/login, /signup) |
private, no-store |
Never cache auth flows |
API responses (/api/user/**) |
private, no-store |
Never cache authenticated data |
Public API (/api/products) |
public, s-maxage=120, stale-while-revalidate=60 |
Cacheable, moderate TTL |
Testing Cache HIT vs MISS Behavior
Most CDNs respond with headers indicating whether the request was served from cache. Test for these to verify cache policies are working:
// tests/cache/hit-miss.test.ts
test('CDN serves from cache on repeat requests', async ({ request }) => {
const url = `${process.env.PRODUCTION_URL}/products/popular-product`;
// First request — should be MISS (populates cache)
const first = await request.get(url);
const firstCacheStatus =
first.headers()['cf-cache-status'] ?? // Cloudflare
first.headers()['x-cache'] ?? // CloudFront/generic
first.headers()['x-cache-status']; // Nginx
console.log('First request cache status:', firstCacheStatus);
// Second request — should be HIT
const second = await request.get(url);
const secondCacheStatus =
second.headers()['cf-cache-status'] ?? second.headers()['x-cache'] ?? second.headers()['x-cache-status'];
expect(secondCacheStatus).toMatch(/^HIT$/i);
});
test('authenticated requests bypass CDN cache', async ({ request }) => {
const url = `${process.env.PRODUCTION_URL}/api/user/dashboard`;
const response = await request.get(url, {
headers: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
});
const cacheStatus = response.headers()['cf-cache-status'] ?? response.headers()['x-cache'];
// Should never be HIT for authenticated requests
expect(cacheStatus).not.toMatch(/^HIT$/i);
});
Testing Cache Invalidation
This is where most teams have zero automated coverage. Cache invalidation failures mean users see stale content after a data update:
sequenceDiagram
participant User
participant CDN
participant Origin
Note over CDN: Old product price ($99) is cached
User->>CDN: GET /products/123
CDN-->>User: Returns cached $99
Note over Origin: Product price updated to $79
Origin->>CDN: POST /__cdn/purge {url: "/products/123"}
User->>CDN: GET /products/123
CDN->>Origin: Cache MISS, forward to origin
Origin-->>CDN: Returns $79
CDN-->>User: Returns fresh $79
Test that your application actually calls the invalidation endpoint when content changes:
// tests/cache/invalidation.test.ts
test('updating product invalidates CDN cache', async ({ request, page }) => {
const productSlug = 'test-product-cdn';
// Step 1: Prime the CDN cache
const primeResponse = await request.get(`/products/${productSlug}`);
expect(primeResponse.status()).toBe(200);
const originalPrice = await extractPriceFromResponse(primeResponse);
// Step 2: Update product via admin API
const updateResponse = await request.patch(`/api/admin/products/${productSlug}`, {
data: { price: 4999 },
headers: { Authorization: `Bearer ${process.env.ADMIN_TOKEN}` },
});
expect(updateResponse.status()).toBe(200);
// Step 3: Wait briefly for invalidation to propagate
await page.waitForTimeout(2000);
// Step 4: Verify fresh content is served
const freshResponse = await request.get(`/products/${productSlug}`);
const freshPrice = await extractPriceFromResponse(freshResponse);
expect(freshPrice).toBe(4999);
expect(freshPrice).not.toBe(originalPrice);
// Step 5: Optionally verify the cache status header shows MISS/EXPIRED
const cacheStatus = freshResponse.headers()['cf-cache-status'] ?? freshResponse.headers()['x-cache'];
expect(cacheStatus).toMatch(/MISS|EXPIRED|BYPASS/i);
});
Common CDN Caching Bugs
Bug 1: Caching Private Responses
If Set-Cookie or sensitive headers appear in a cached response, users can see each other's data:
test('no Set-Cookie headers on public cached routes', async ({ request }) => {
const response = await request.get('/products/public-product');
// Public, cacheable routes must not set cookies
// (or the CDN may cache a user-specific response)
const setCookie = response.headers()['set-cookie'];
const cacheControl = response.headers()['cache-control'];
if (cacheControl?.includes('public') && !cacheControl?.includes('private')) {
expect(setCookie).toBeUndefined();
}
});
Bug 2: Query Parameters Bypassing Cache Keys
test('UTM parameters do not create separate cache entries', async ({ request }) => {
const baseUrl = '/products/sample';
const withUtm = '/products/sample?utm_source=email&utm_campaign=spring';
// Both URLs should return identical content
const [base, utm] = await Promise.all([request.get(baseUrl), request.get(withUtm)]);
const baseBody = await base.text();
const utmBody = await utm.text();
expect(baseBody).toBe(utmBody);
});
Bug 3: Vary Header Misuse
test('Vary:Accept-Encoding does not leak between users', async ({ request }) => {
const url = '/products/sample';
const [withGzip, withoutGzip] = await Promise.all([
request.get(url, { headers: { 'Accept-Encoding': 'gzip' } }),
request.get(url, { headers: { 'Accept-Encoding': 'identity' } }),
]);
// Both should return 200, just different encoding
expect(withGzip.status()).toBe(200);
expect(withoutGzip.status()).toBe(200);
// Content should be semantically equivalent
// (decompression handled by playwright automatically)
});
Related articles: Also see the caching strategy guide that defines what you are testing, the full performance picture your CDN testing feeds into, and frontend performance testing to pair with your CDN validation.
CI Monitoring for CDN Regression
After every production deploy, validate that cache headers haven't regressed:
# .github/workflows/cache-checks.yml
name: CDN Cache Validation
on:
deployment_status:
types: [success]
jobs:
validate-cache-headers:
if: github.event.deployment_status.environment == 'production'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check critical route cache headers
run: |
# Static assets must be immutable
CACHE=$(curl -sI https://scanlyapp.com/_next/static/chunks/main.js | grep -i cache-control)
echo "Static: $CACHE"
echo "$CACHE" | grep -q "immutable" || (echo "ERROR: Static assets not immutable" && exit 1)
# Login page must not be cached
CACHE=$(curl -sI https://app.scanlyapp.com/login | grep -i cache-control)
echo "Login: $CACHE"
echo "$CACHE" | grep -qiE "private|no-store" || (echo "ERROR: Login page is cacheable" && exit 1)
Cache misconfigurations are silent bugs — your application appears to work, but users are seeing stale content or, worse, each other's data. Automated header assertions in CI are the lowest-effort way to prevent this class of regression.
Catch cache and header regressions on every deploy: Try ScanlyApp free and set up automated post-deploy checks that verify your CDN and HTTP response headers are correct.
