← Networking
Technique Network design and protocols

HTTP caching and CDNs

A cache can save a round trip only when it knows what response it may reuse.

A popular catalog page makes repeated trips to the origin, then one user reports seeing old content after a deployment. The fix depends on which response was stored, how long it was fresh, whether it was checked, and which cache served it. Follow the response policy and validator before purging anything.

Follow the representation

Decide what can be stored, when it must be checked, and which cache has evidence about the response.

TypeScriptGo Freshness · validators · shared edges
01 / Start with repeated work

Ask which response was stored and who stored it.

HTTP caching can let a browser, proxy, or shared edge reuse a stored response. That can reduce transfer and origin work, but only when the response’s cache directives and cache key allow reuse for this request. A response has more identity than a URL if its representation varies by request headers or authorization.

Begin with the full request URL, method, relevant request headers, response Cache-Control, validators, and the cache layer observed. Check browser developer tools and response headers; compare with a direct origin request only if you can do so safely. CDN routing and cache policy are provider-specific, so write down which hostname and path you tested.

02 / Decide whether a response is reusable

Freshness lets a cache answer without checking the origin.

Cache-Control: max-age=60 makes a response fresh for a stated duration under HTTP caching rules. While it remains fresh, a cache can reuse it without contacting the origin. When it becomes stale, the cache may need to validate it or obtain a replacement, subject to the directives and request context.

no-cache does not mean “do not store.” It allows storage but requires validation before reuse. no-store tells caches not to store the request or response. must-revalidate constrains reuse of a stale response when validation is required. Choose the directive based on the resource’s sensitivity and how quickly its representation must reflect a change.

Common policy differences; the complete semantics are defined by the HTTP caching specification
DirectiveWhat it communicatesUse with care
max-age=NResponse freshness lifetime in seconds.Pick the lifetime based on update behavior and acceptable staleness.
no-cacheA stored response must be validated before reuse.Validation can still use a small request and return 304.
no-storeDo not store this request or response.Use for sensitive data where storage is not appropriate.
privateShared caches must not store the response as a shared response.It can still be stored by a private cache unless another directive forbids it.
03 / Revalidate with a validator

An unchanged representation can be confirmed without sending its body again.

An origin can attach an ETag to a representation. When a stored response becomes stale, a client or cache can send that validator back as If-None-Match. If the representation still matches, the server returns 304 Not Modified; the stored body can be reused. If it changed, the server returns a new representation, typically with 200 OK and a new validator.

A 304 response does not carry a replacement representation body. It updates relevant stored metadata according to HTTP rules. Validators reduce repeated body transfer; validation still involves a request to the origin or an intermediary able to answer for it.

First request200 + body + ETag

Cache stores the representation and validator.

Later requestIf-None-Match

Cache sends the stored validator after freshness expires.

Unchanged304, no body

Reuse the stored body and update metadata.

Changed200 + new body

Replace the stored representation and validator.

04 / Protect the representation boundary

A cache key must distinguish responses that are not interchangeable.

A cache’s primary key includes at least the target URI and method behavior required by HTTP. A response can also vary based on selected request headers. The Vary response field names headers that participate in representation selection; for example, a language-specific response may vary on Accept-Language. If a cache ignores a meaningful variant, it can serve the wrong representation.

Shared caches need special care with authorization and personalized content. Follow the cache directives and HTTP rules for authenticated requests; do not assume that a response safe for a user’s private browser is safe for a shared edge. If the response contains private account data, define an explicit policy and test with distinct identities before enabling shared reuse.

05 / Place a CDN on the request path

An edge cache is a shared intermediary, not a new DNS answer for every request.

A typical request first resolves a hostname, then establishes an HTTP connection to an address. A CDN can terminate that connection at an edge, serve a cached response, revalidate it, or forward the request to an origin. DNS may help direct users to a service address; an anycast address may be routed toward one of several nodes. These are separate steps from HTTP cache selection.

Anycast routing does not guarantee the geographically nearest node or balanced load. Network topology and provider routing policies choose a reachable destination, and CDN providers can combine DNS steering, anycast, and other mechanisms. Purge behavior, cache-key controls, and cache status headers also vary by provider.

Name lookupResolver

Returns address information for the hostname.

Connection + requestClient → edge

Routing and provider policy select a serving edge.

Cache decisionFresh or validate

Edge can serve, revalidate, or forward.

On a missOrigin

Returns a representation or validation result.

06 / Watch a local conditional request

Validate the same body, then publish a changed representation.

The paired programs run an origin on 127.0.0.1 and a small manual client cache in the same process. The origin returns a catalog body and ETag, accepts a matching If-None-Match with 304, and increments its version after a local publish request. The client stores the body, reuses it after validation, then stores the changed body.

This demonstrates conditional HTTP behavior only. It is not a complete browser cache, shared cache, or CDN. It does not implement the full cache key, age, freshness, privacy, or invalidation rules. Use the standards and the actual provider configuration for those decisions.

Compare conditional validation in TypeScript and Go.

Both local examples store a body, send its ETag, and handle 304 or a changed 200.

TypeScriptLoopback HTTP cache exercise · 200, 304, changed 200
cache.ts
import { createServer, request } from 'node:http';
import type { AddressInfo } from 'node:net';

type Reply = { status: number; body: string; etag?: string };

function call(
	port: number,
	path: string,
	method = 'GET',
	headers: Record<string, string> = {}
): Promise<Reply> {
	return new Promise((resolve, reject) => {
		const req = request({ host: '127.0.0.1', port, path, method, headers }, (response) => {
			response.setEncoding('utf8');
			let body = '';
			response.on('data', (chunk: string) => (body += chunk));
			response.on('end', () =>
				resolve({
					status: response.statusCode ?? 0,
					body,
					etag: response.headers.etag
				})
			);
		});
		req.once('error', reject);
		req.end();
	});
}

async function main() {
	// This origin is private to the process and binds only to IPv4 loopback.
	let version = 1;
	const server = createServer((req, response) => {
		if (req.method === 'POST' && req.url === '/publish') {
			version += 1;
			response.writeHead(204);
			response.end();
			return;
		}
		if (req.method !== 'GET' || req.url !== '/catalog') {
			response.writeHead(404);
			response.end();
			return;
		}

		const etag = `"catalog-v${version}"`;
		const cacheControl = 'public, max-age=0, must-revalidate';
		response.setHeader('Cache-Control', cacheControl);
		response.setHeader('ETag', etag);
		if (req.headers['if-none-match'] === etag) {
			console.log(`origin: validator matched ${etag}; returning 304 without a body`);
			response.writeHead(304);
			response.end();
			return;
		}

		console.log(`origin: sending representation ${etag}`);
		response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
		response.end(JSON.stringify({ version, items: ['router', 'switch', 'service'] }));
	});

	await new Promise<void>((resolve, reject) => {
		server.once('error', reject);
		server.listen(0, '127.0.0.1', resolve);
	});
	const address = server.address();
	if (!address || typeof address === 'string') throw new Error('Expected a local TCP address.');
	const port = (address as AddressInfo).port;
	let stored: { etag: string; body: string } | undefined;

	async function readThroughCache() {
		const reply = await call(
			port,
			'/catalog',
			'GET',
			stored ? { 'if-none-match': stored.etag } : {}
		);
		if (reply.status === 304 && stored) {
			console.log(`client: reused stored body for ${stored.etag}: ${stored.body}`);
			return;
		}
		if (reply.status !== 200 || !reply.etag) throw new Error(`Unexpected response ${reply.status}`);
		stored = { etag: reply.etag, body: reply.body };
		console.log(`client: stored ${stored.etag}: ${stored.body}`);
	}

	try {
		await readThroughCache(); // 200: store a body and its validator.
		await readThroughCache(); // 304: validate, then reuse the stored body.
		await call(port, '/publish', 'POST'); // Change the local origin representation.
		await readThroughCache(); // 200: the old validator no longer matches.
	} finally {
		await new Promise<void>((resolve) => server.close(() => resolve()));
	}
}

void main().catch((error: unknown) => {
	console.error(error);
	process.exitCode = 1;
});
GoLoopback HTTP cache exercise · 200, 304, changed 200
cache.go
package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"sync"
)

func main() {
	// This origin is private to the process and binds only to IPv4 loopback.
	var mu sync.Mutex
	version := 1
	mux := http.NewServeMux()
	mux.HandleFunc("/publish", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}
		mu.Lock()
		version++
		mu.Unlock()
		w.WriteHeader(http.StatusNoContent)
	})
	mux.HandleFunc("/catalog", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodGet {
			w.WriteHeader(http.StatusMethodNotAllowed)
			return
		}
		mu.Lock()
		current := version
		mu.Unlock()

		etag := fmt.Sprintf(`"catalog-v%d"`, current)
		w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
		w.Header().Set("ETag", etag)
		if r.Header.Get("If-None-Match") == etag {
			fmt.Printf("origin: validator matched %s; returning 304 without a body\n", etag)
			w.WriteHeader(http.StatusNotModified)
			return
		}

		fmt.Printf("origin: sending representation %s\n", etag)
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_, _ = fmt.Fprintf(w, `{"version":%d,"items":["router","switch","service"]}`, current)
	})

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	server := &http.Server{Handler: mux}
	go func() { _ = server.Serve(listener) }()
	defer server.Close()
	client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
	defer client.CloseIdleConnections()
	baseURL := "http://" + listener.Addr().String()
	var storedBody, storedETag string

	readThroughCache := func() error {
		req, err := http.NewRequest(http.MethodGet, baseURL+"/catalog", nil)
		if err != nil {
			return err
		}
		if storedETag != "" {
			req.Header.Set("If-None-Match", storedETag)
		}
		response, err := client.Do(req)
		if err != nil {
			return err
		}
		defer response.Body.Close()

		if response.StatusCode == http.StatusNotModified && storedETag != "" {
			fmt.Printf("client: reused stored body for %s: %s\n", storedETag, storedBody)
			return nil
		}
		if response.StatusCode != http.StatusOK {
			return fmt.Errorf("unexpected response: %s", response.Status)
		}
		body, err := io.ReadAll(response.Body)
		if err != nil {
			return err
		}
		storedBody = string(body)
		storedETag = response.Header.Get("ETag")
		if storedETag == "" {
			return fmt.Errorf("origin omitted ETag")
		}
		fmt.Printf("client: stored %s: %s\n", storedETag, storedBody)
		return nil
	}

	if err := readThroughCache(); err != nil {
		panic(err)
	}
	if err := readThroughCache(); err != nil {
		panic(err)
	}
	publish, err := http.NewRequest(http.MethodPost, baseURL+"/publish", nil)
	if err != nil {
		panic(err)
	}
	response, err := client.Do(publish)
	if err != nil {
		panic(err)
	}
	response.Body.Close()
	if err := readThroughCache(); err != nil {
		panic(err)
	}
}
07 / Choose policy and verify

Set a policy for the data, then test the layer that serves it.

The cache rules are specified in RFC 9111, HTTP Caching; general status and validator semantics are in RFC 9110, HTTP Semantics. For anycast service routing, see RFC 4786. These define protocol behavior; a CDN’s routing, cache-key controls, and purge interface remain provider-specific.