Published on

HTTP Caching in Production — Cache-Control, ETags, and CDN Integration

Authors

Introduction

HTTP caching is the most underutilized performance lever in modern applications. Most teams optimize database queries and compute while leaving trillions of bytes of potential cache hits on the table. Understanding Cache-Control headers, ETags, and CDN behavior is essential for building fast, scalable systems that degrade gracefully under load.

Cache-Control Directives Explained

Cache-Control is a complex header with many directives. Each directive controls different aspects of cache behavior:

// Express middleware for different cache strategies
import { Response } from 'express';

// Static assets: cache for 1 year (with cache busting in filename)
function setCacheStatic(res: Response) {
  res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}

// API responses: cache for 60s in CDN and browser
function setCacheApi(res: Response) {
  res.setHeader('Cache-Control', 'public, max-age=60, s-maxage=60, must-revalidate');
}

// User-specific content: no shared cache
function setCachePrivate(res: Response) {
  res.setHeader('Cache-Control', 'private, max-age=300, must-revalidate');
}

// HTML with graceful degradation
function setCacheHtml(res: Response) {
  res.setHeader(
    'Cache-Control',
    'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=604800'
  );
}

// Query: max-age (browser), s-maxage (shared/CDN), stale-* for degradation
// max-age=0: browser revalidates every time
// s-maxage=3600: CDN caches for 1 hour
// stale-while-revalidate=86400: serve stale for 24h while revalidating in background
// stale-if-error=604800: serve stale for 7 days if origin is down

Breaking down the directives:

interface CacheControlOptions {
  // "public" or "private": Can shared caches (CDN) cache this?
  accessibility: 'public' | 'private';

  // Seconds before browser cache is considered stale
  maxAge?: number;

  // Seconds before CDN/shared cache is considered stale (overrides max-age for shared caches)
  sMaxAge?: number;

  // Must revalidate with origin (even if stale)
  mustRevalidate?: boolean;

  // Proxy must revalidate (like must-revalidate but for shared caches only)
  proxyRevalidate?: boolean;

  // Serve stale response while revalidating in background
  staleWhileRevalidate?: number;

  // Serve stale response if origin is unreachable
  staleIfError?: number;

  // Asset hash in filename - never changes, safe to cache forever
  immutable?: boolean;

  // No caching at all
  noCache?: boolean; // Revalidate before using
  noStore?: boolean; // Don't cache or store
}

// Production helper
export function buildCacheControl(options: CacheControlOptions): string {
  const parts: string[] = [];

  parts.push(options.accessibility); // 'public' or 'private'

  if (options.maxAge !== undefined) {
    parts.push(`max-age=${options.maxAge}`);
  }

  if (options.sMaxAge !== undefined) {
    parts.push(`s-maxage=${options.sMaxAge}`);
  }

  if (options.mustRevalidate) {
    parts.push('must-revalidate');
  }

  if (options.proxyRevalidate) {
    parts.push('proxy-revalidate');
  }

  if (options.staleWhileRevalidate !== undefined) {
    parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
  }

  if (options.staleIfError !== undefined) {
    parts.push(`stale-if-error=${options.staleIfError}`);
  }

  if (options.immutable) {
    parts.push('immutable');
  }

  return parts.join(', ');
}

Strong vs Weak ETags

ETags enable cache validation when max-age expires. Strong ETags guarantee byte-for-byte equality; weak ETags allow semantically equivalent responses.

import crypto from 'crypto';

// STRONG ETag: Changes if any byte differs
// Used for exact content matching (APIs, downloads)
function generateStrongETag(content: string): string {
  const hash = crypto.createHash('sha256').update(content).digest('hex');
  return `"${hash}"`;
}

// WEAK ETag: Allow for minor formatting differences
// Used for HTML (whitespace, comments irrelevant)
function generateWeakETag(content: string): string {
  const hash = crypto.createHash('md5').update(content).digest('hex');
  return `W/"${hash}"`;
}

// Production middleware
function etagMiddleware(req: Request, res: Response, next: NextFunction) {
  const originalJson = res.json;

  res.json = function(body: any) {
    const content = JSON.stringify(body);
    const etag = generateStrongETag(content);

    res.setHeader('ETag', etag);
    res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');

    // Client checks If-None-Match header
    if (req.headers['if-none-match'] === etag) {
      res.status(304); // Not Modified
      return res.end();
    }

    return originalJson.call(this, body);
  };

  next();
}

Conditional Requests and Revalidation

When cache expires, clients revalidate using weak validation:

// Client sends:
// If-None-Match: "abc123" (from ETag)
// If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT

// Server responds:
// 304 Not Modified (if unchanged)
// 200 OK with new content + new ETag (if changed)

import { Router } from 'express';

const router = Router();

interface CachedResource {
  id: string;
  content: string;
  lastModified: Date;
  hash: string;
}

router.get('/api/resource/:id', (req: Request, res: Response) => {
  const resource = getResource(req.params.id) as CachedResource;

  // Check ETag
  if (req.headers['if-none-match'] === resource.hash) {
    res.status(304).end();
    return;
  }

  // Check Last-Modified
  const ifModifiedSince = req.headers['if-modified-since'];
  if (ifModifiedSince) {
    const clientTime = new Date(ifModifiedSince).getTime();
    const serverTime = resource.lastModified.getTime();

    if (serverTime <= clientTime) {
      res.status(304).end();
      return;
    }
  }

  // Resource changed, send full response with validators
  res.setHeader('ETag', resource.hash);
  res.setHeader('Last-Modified', resource.lastModified.toUTCString());
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');

  res.json(resource);
});

Vary Header for Content Negotiation

When responses differ based on request headers (Accept-Encoding, Accept-Language), use Vary:

// Response varies by these request headers
res.setHeader('Vary', 'Accept-Encoding, Accept-Language, Authorization');

// Tells caches: Don't serve the gzip version to a non-gzip client
// Tells browsers: Store separate cache entries for different Accept-Encoding values

// Production pattern
interface VaryOptions {
  acceptEncoding?: boolean;
  acceptLanguage?: boolean;
  authorization?: boolean;
  userAgent?: boolean;
  cookie?: boolean;
}

function setVaryHeader(res: Response, options: VaryOptions) {
  const headers: string[] = [];

  if (options.acceptEncoding) headers.push('Accept-Encoding');
  if (options.acceptLanguage) headers.push('Accept-Language');
  if (options.authorization) headers.push('Authorization');
  if (options.userAgent) headers.push('User-Agent');
  if (options.cookie) headers.push('Cookie');

  if (headers.length > 0) {
    res.setHeader('Vary', headers.join(', '));
  }
}

// Usage
setVaryHeader(res, {
  acceptEncoding: true,
  acceptLanguage: true,
  authorization: true,
});

Cache Busting Strategies

Different content types require different busting approaches:

// 1. Filename hashing (for static assets)
// app.v3a4f9d2.js - changes hash when content changes
// Served with: Cache-Control: max-age=31536000, immutable

// 2. Query string versioning (for API endpoints)
// GET /api/config?v=1.2.3
// Served with: Cache-Control: max-age=86400

// 3. URL path versioning (for backwards compatibility)
// GET /api/v2/users
// Served with: Cache-Control: max-age=3600

import crypto from 'crypto';

function generateAssetHash(content: Buffer): string {
  return crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
}

// Build-time: hash.json
interface AssetManifest {
  [assetPath: string]: string; // maps /static/app.js to /static/app.a3f9d2c1.js
}

function buildAssetManifest(): AssetManifest {
  return {
    '/static/app.js': `/static/app.${generateAssetHash(readFile('/static/app.js'))}.js`,
    '/static/styles.css': `/static/styles.${generateAssetHash(readFile('/static/styles.css'))}.css`,
  };
}

// Server-side rendering: inject versioned URLs
function renderHtml(manifest: AssetManifest): string {
  return `
    <!DOCTYPE html>
    <script src="${manifest['/static/app.js']}"></script>
    <link rel="stylesheet" href="${manifest['/static/styles.css']}" />
  `;
}

CloudFront Cache Behavior Configuration

AWS CloudFront lets you configure cache behavior per path pattern:

// Terraform/CDK configuration
interface CloudFrontCacheBehavior {
  pathPattern: string;
  cachePolicy: {
    maxTtl: number;     // Max cache time (seconds)
    defaultTtl: number; // Default if Cache-Control max-age missing
    minTtl: number;     // Minimum cache time (override origin)
  };
  compress: boolean;
  viewerProtocolPolicy: 'https-only' | 'allow-all' | 'redirect-to-https';
  allowedMethods: string[];
  cachedMethods: string[];
  forwardedValues: {
    queryString: boolean;
    cookies?: string[];
    headers?: string[];
  };
}

const cacheBehaviors: CloudFrontCacheBehavior[] = [
  {
    pathPattern: '/static/*',
    cachePolicy: { maxTtl: 31536000, defaultTtl: 31536000, minTtl: 0 },
    compress: true,
    viewerProtocolPolicy: 'https-only',
    allowedMethods: ['GET', 'HEAD'],
    cachedMethods: ['GET', 'HEAD'],
    forwardedValues: { queryString: false },
  },
  {
    pathPattern: '/api/*',
    cachePolicy: { maxTtl: 3600, defaultTtl: 60, minTtl: 0 },
    compress: true,
    viewerProtocolPolicy: 'https-only',
    allowedMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'PATCH', 'DELETE'],
    cachedMethods: ['GET', 'HEAD'],
    forwardedValues: { queryString: true, headers: ['Authorization'] },
  },
];

Cache Stampede Prevention

When a popular item's cache expires simultaneously, all requests hit the origin. Early revalidation prevents this:

import { Router } from 'express';

const router = Router();

// Cache stampede prevention pattern
class CacheManager {
  private revalidatingKeys = new Set<string>();

  async getWithStampedeProtection<T>(
    key: string,
    cacheTtl: number,
    revalidateWindow: number, // Seconds before expiry to start revalidating
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cached = cache.get(key);
    const now = Date.now();

    if (cached && cached.expiresAt > now) {
      const timeToExpiry = cached.expiresAt - now;

      // Trigger background revalidation if within window
      if (timeToExpiry < revalidateWindow * 1000 && !this.revalidatingKeys.has(key)) {
        this.revalidatingKeys.add(key);

        fetcher()
          .then(data => {
            cache.set(key, data, cacheTtl);
          })
          .catch(err => {
            console.error(`Failed to revalidate ${key}:`, err);
          })
          .finally(() => {
            this.revalidatingKeys.delete(key);
          });
      }

      return cached.data;
    }

    // Cache miss, fetch fresh data
    const data = await fetcher();
    cache.set(key, data, cacheTtl);
    return data;
  }
}

router.get('/api/popular-endpoint', async (req, res) => {
  const manager = new CacheManager();

  const data = await manager.getWithStampedeProtection(
    'popular-data',
    3600, // 1 hour cache
    600,  // Revalidate starting 10 minutes before expiry
    async () => {
      return db.popular.findMany();
    }
  );

  res.json(data);
});

Surrogate Keys for Targeted Invalidation

CDNs support surrogate keys for granular cache purging:

// Set on origin response
res.setHeader('Surrogate-Key', 'user-123 blog-post-456 author-789');

// Later, invalidate specific keys without purging entire cache
async function invalidatePostCache(postId: string) {
  await cloudfront.createInvalidation({
    DistributionId: DISTRIBUTION_ID,
    InvalidationBatch: {
      Paths: {
        Quantity: 1,
        Items: [`/blog/${postId}`],
      },
    },
  });
}

// Or with surrogate key header (if CDN supports it)
async function invalidateBySurrogateKey(surrogateKey: string) {
  // Fastly example
  await fetch('https://api.fastly.com/purge', {
    method: 'POST',
    headers: {
      'Fastly-Key': FASTLY_API_KEY,
      'Surrogate-Key': surrogateKey,
    },
  });
}

Checklist

  • Classify endpoints into cache profiles (static, api, html, private)
  • Use appropriate Cache-Control directives for each profile
  • Implement ETag generation for dynamic content
  • Add 304 Not Modified responses to reduce bandwidth
  • Set Vary headers when response content depends on request headers
  • Use content-hash filenames for immutable static assets
  • Configure CDN cache behaviors per path pattern
  • Implement early revalidation to prevent cache stampedes
  • Test cache behavior with curl headers (If-None-Match, If-Modified-Since)
  • Monitor cache hit ratios in CDN and adjust TTLs accordingly

Conclusion

HTTP caching is a layered system requiring coordination between origin, CDN, and browsers. Proper Cache-Control directives, ETag validation, and cache busting strategies transform your application's performance from application-dependent to network-dependent. The investment in understanding these mechanisms pays dividends in reduced latency, lower bandwidth costs, and graceful degradation when origins falter.