Skip to content

HTTP Caching & CDN

Cache-Control, ETags, and CDN edge caching — the layer that can eliminate your server entirely for static content.

Why HTTP Caching Exists

Every HTTP response can carry instructions about how it should be cached — by browsers, proxies, and CDN edge nodes. When these instructions are set correctly, repeat requests for the same resource never reach your server at all.

The problem it solves: serving the same bytes to millions of users is wasteful. A 500KB JavaScript bundle served from a single origin to 10 million users is 5TB of transfer. HTTP caching means most of those users never contact your origin.

Real-World Analogy

A newspaper printer prints 100,000 copies in the morning. Each copy goes to a delivery depot (CDN edge node). Readers pick up from the depot, not the printer. The printer only runs when there’s a new edition. HTTP caching is the same: your server is the printer, CDN edges are the depots, and Cache-Control tells the depot how long to keep today’s edition before discarding it.

Cache-Control Header

The primary mechanism. Controls who can cache, for how long, and under what conditions.

Cache-Control: public, max-age=31536000, immutable

Key directives:

DirectiveMeaning
publicCDNs and proxies can cache this
privateOnly the browser can cache (not CDN)
no-storeNever cache anywhere
no-cacheCache but revalidate before serving
max-age=NFresh for N seconds
s-maxage=NCDN freshness (overrides max-age for CDNs)
stale-while-revalidate=NServe stale for N seconds while refreshing
immutableNever revalidate during max-age (browser hint)
must-revalidateMust contact origin when stale, never serve expired
// Express/Node.js — set cache headers
app.get('/api/products/:id', async (req, res) => {
	const product = await getProduct(req.params.id);

	// Public, 5 minute CDN cache, serve stale for 30s while revalidating
	res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=30');
	res.json(product);
});

// Immutable assets (content-hashed filenames)
app.use(
	'/assets',
	express.static('dist/assets', {
		setHeaders: (res) => {
			res.set('Cache-Control', 'public, max-age=31536000, immutable');
		}
	})
);

// Private, user-specific data
app.get('/api/me', authenticate, (req, res) => {
	res.set('Cache-Control', 'private, max-age=60');
	res.json(req.user);
});

Content-hash your asset filenames. app.js?v=1.2.3 is fragile — someone may cache the old file and ignore the version query. app.a4f8c2b1.js is the hash of the file content — when the file changes, the URL changes, and browsers fetch fresh automatically. Then you can safely set max-age=31536000.

ETag and Conditional Requests

An ETag is a fingerprint of the response body. On subsequent requests, the browser sends it back; the server validates and either returns fresh data or 304 Not Modified (no body, saves bandwidth).

import { createHash } from 'crypto';

function generateETag(content: string): string {
	return `"${createHash('md5').update(content).digest('hex')}"`;
}

app.get('/api/config', async (req, res) => {
	const config = await getConfig();
	const body = JSON.stringify(config);
	const etag = generateETag(body);

	// Client sends If-None-Match: "abc123" on repeat requests
	if (req.headers['if-none-match'] === etag) {
		return res.status(304).end(); // Not Modified — no body sent
	}

	res.set('ETag', etag);
	res.set('Cache-Control', 'public, max-age=60, must-revalidate');
	res.json(config);
});

Last-Modified / If-Modified-Since — older, timestamp-based equivalent:

app.get('/api/posts/:id', async (req, res) => {
	const post = await getPost(req.params.id);
	const lastModified = post.updatedAt.toUTCString();

	if (req.headers['if-modified-since'] === lastModified) {
		return res.status(304).end();
	}

	res.set('Last-Modified', lastModified);
	res.set('Cache-Control', 'public, max-age=300');
	res.json(post);
});

CDN Edge Caching

A CDN places servers (PoPs — points of presence) close to users worldwide. Requests hit the nearest PoP. If the PoP has the response cached, it serves it without ever contacting your origin.

User (London) → Cloudflare London PoP → cached response (2ms)
                                       ↓ cache miss
                                       → Your origin (Frankfurt) → 20ms
// Cloudflare Cache API (Workers)
export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const cacheKey = new Request(request.url, request);
		const cache = caches.default;

		// Check edge cache
		let response = await cache.match(cacheKey);
		if (response) return response;

		// Cache miss — fetch from origin
		response = await fetch(request);

		// Cache the response at the edge
		const responseToCache = new Response(response.body, response);
		responseToCache.headers.set('Cache-Control', 'public, max-age=300');
		await cache.put(cacheKey, responseToCache);

		return response;
	}
};

CDN cache purging — when content changes, purge the CDN cache:

// Cloudflare API purge
async function purgeCloudflare(urls: string[]): Promise<void> {
	await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`, {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${CLOUDFLARE_TOKEN}`,
			'Content-Type': 'application/json'
		},
		body: JSON.stringify({ files: urls })
	});
}

// On product update, purge CDN cache
async function updateProduct(id: string, data: Partial<Product>): Promise<void> {
	await db.products.update(id, data);
	await purgeCloudflare([
		`https://yoursite.com/api/products/${id}`,
		`https://yoursite.com/products/${id}`
	]);
}

Vary Header

Tells CDNs to cache different versions based on request headers:

// Different response for mobile vs desktop
res.set('Vary', 'User-Agent'); // ⚠️ terrible — too many variations

// Better: use a normalized hint
res.set('Vary', 'Accept-Encoding'); // compressed vs uncompressed
res.set('Vary', 'Accept'); // JSON vs HTML

Avoid Vary: User-Agent. User-Agent strings are nearly infinite. CDNs create a separate cache entry per variation — your cache hit ratio plummets. If you need device-specific content, serve it from different URLs or use Client Hints instead.

Common Caching Patterns

Static assets (JS, CSS, images):

Cache-Control: public, max-age=31536000, immutable

Cache forever. When the file changes, the URL changes (content hash).

API responses (cacheable):

Cache-Control: public, max-age=60, stale-while-revalidate=30

Fresh for 1 minute, serve stale for 30 extra seconds while revalidating.

User-specific API responses:

Cache-Control: private, max-age=30

Browser can cache, CDN cannot.

Never cache:

Cache-Control: no-store

Mutations, payments, sensitive user data.

HTML pages (SPA shell):

Cache-Control: public, max-age=0, must-revalidate
ETag: "abc123"

Always revalidate but serve the cached version if ETag matches (304 response).

Cache-Control Strategy by Resource Type

function getCacheHeaders(resource: 'asset' | 'api' | 'html' | 'user-data'): string {
	switch (resource) {
		case 'asset':
			return 'public, max-age=31536000, immutable';
		case 'api':
			return 'public, max-age=60, stale-while-revalidate=30';
		case 'html':
			return 'public, max-age=0, must-revalidate';
		case 'user-data':
			return 'private, max-age=30';
		default:
			return 'no-store';
	}
}

Debugging HTTP Cache

# Check response headers
curl -I https://yoursite.com/api/products/1

# Check cache status (Cloudflare adds cf-cache-status)
# HIT = served from CDN edge
# MISS = fetched from origin
# EXPIRED = stale, re-fetched
# BYPASS = cache bypassed

# Chrome DevTools → Network → Response Headers → Cache-Control, Age, cf-cache-status
# Age header tells you how old the cached response is (seconds since origin served it)

The Age header is your best debugging tool. If Age: 0, the CDN just fetched from origin. If Age: 240, this response has been cached for 4 minutes.