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.
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.
| Directive | What it communicates | Use with care |
|---|---|---|
max-age=N | Response freshness lifetime in seconds. | Pick the lifetime based on update behavior and acceptable staleness. |
no-cache | A stored response must be validated before reuse. | Validation can still use a small request and return 304. |
no-store | Do not store this request or response. | Use for sensitive data where storage is not appropriate. |
private | Shared caches must not store the response as a shared response. | It can still be stored by a private cache unless another directive forbids it. |
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.
Cache stores the representation and validator.
Cache sends the stored validator after freshness expires.
Reuse the stored body and update metadata.
Replace the stored representation and validator.
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.
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.
Returns address information for the hostname.
Routing and provider policy select a serving edge.
Edge can serve, revalidate, or forward.
Returns a representation or validation result.
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.
Both local examples store a body, send its ETag, and handle 304 or a changed 200.
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;
});
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)
}
}
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.