Back to Blog

Testing CDN Caching Rules and Cache Invalidation: A Developer's Guide

CDN misconfiguration is one of the hardest bugs to catch in QA — it works perfectly in staging (which bypasses the CDN) but fails in production. Learn how to test cache headers, validate invalidation logic, and build automated checks that keep your caching layer honest.

Published

6 min read

Reading time

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.

Related Posts