← Back to the library
BackendHow it works · Applied · 14 min

HTTP caching: Cache-Control, validators and CDNs

Which cache answers this request, how long may it reuse the response, and how do you change it safely after a deploy?

Every response carries instructions for the browser cache and every shared cache between it and your origin. Freshness (max-age, s-maxage) decides when a stored copy may be reused without asking; validators (ETag, Last-Modified) make asking cheap with a 304.

THE MENTAL MODEL

Think of each cache as a key-value store keyed on method plus URL (plus any request headers named in Vary), with an expiry stamped on each entry. While an entry is fresh, the cache answers alone and your origin never hears about the request. Once stale, the cache must ask the origin "has this changed?" with the validator it stored, and a 304 renews the entry without resending the body. The browser cache is private and you cannot purge it; CDN caches are shared and purgeable. So the design rule follows: give URLs that never change (hashed assets) a year of freshness, make URLs that do change (HTML, APIs) short-lived or always revalidated, and never let a shared cache store anything personalised.

HOW IT FITS TOGETHER

A GET through browser cache, CDN and originEach layer can answer on its own while its copy is fresh. Revalidation sends validators upstream and gets a body-less 304 when nothing changed.
  1. Browser HTTP cache lookupKey is method + URL, refined by Vary. A fresh entry (age < max-age) is used with no network request at all.
  2. Stale: conditional requestThe browser sends If-None-Match with the stored ETag and/or If-Modified-Since with Last-Modified.
  3. CDN edge lookupKey is the CDN's cache key (host, path, query, chosen headers). Freshness comes from s-maxage, CDN-Cache-Control or an edge TTL rule.
  4. Edge stale: revalidate or SWRThe edge revalidates with the origin, or with stale-while-revalidate serves the stale copy now and refreshes in the background.
  5. Origin compares validatorsUnchanged: 304 Not Modified with headers only. Changed: 200 with the new body, a new ETag and fresh Cache-Control.
  6. Edge stores and answersThe edge resets the entry's age and returns 304 to the browser if the browser's validator still matches, else the full 200.
  7. Browser renews its entryA 304 updates the stored headers and freshness; the cached body is reused without being downloaded again.
Directive recipes by response typeStart from these and deviate on purpose. The URL's mutability decides the recipe, not the file type.
Cache-ControlValidatorShared cache / CDNWhy
HTML documentno-cache (or max-age=0, must-revalidate)ETag or Last-ModifiedShort edge TTL plus purge on deploy, or bypassIts URL is stable but its content points at new asset hashes
Hashed asset (app.3f9c1a.js)public, max-age=31536000, immutableOptional; rarely usedCache for a year; never purgeA content change produces a new URL, so the old one never goes stale
Un-hashed static (/logo.png)public, max-age=3600 or soETagEdge TTL; purge by URL when replacedThe same URL can change, so freshness must stay short
Public API GETpublic, max-age=30, stale-while-revalidate=60ETag (weak is fine)Cache only if the response is the same for all usersAbsorbs read spikes; SWR hides refresh latency
Per-user API dataprivate, no-cacheETagNever stored (private)A shared cache must not hand one user's data to another
Secrets, tokens, bankingno-storeNoneNever storedNothing may be written to disk or reused, including in the back/forward path

KEY TERMS

Freshness: max-age and s-maxage
max-age=N means the response may be reused without asking for N seconds after the origin generated it (caches subtract the Age header). s-maxage applies only to shared caches, overrides max-age there, and also implies proxy-revalidate, so shared caches must not serve it stale. Without any explicit lifetime, caches may apply heuristic freshness (typically 10% of the time since Last-Modified), which is why you should always send Cache-Control.
no-cache vs no-store, private vs public
no-cache does not mean "do not cache": the response may be stored but must be revalidated before every reuse. no-store forbids storing it anywhere. private restricts storage to the browser; public allows shared caches to store it even for requests that carried Authorization, which shared caches otherwise must not reuse.
immutable, stale-while-revalidate, stale-if-error
immutable tells the browser not to revalidate a fresh response even on reload; use it only on hashed URLs. stale-while-revalidate=N lets a cache serve a stale copy for N more seconds while it refreshes in the background. stale-if-error=N lets it serve stale for N seconds when the origin returns 500, 502, 503 or 504. Support is uneven: Cloudflare ignores both when s-maxage or must-revalidate is present, and the Workers Cache API ignores both.
Validators and 304
ETag is an opaque version identifier (strong, or weak W/"..." for semantically equivalent bodies); Last-Modified is a date with one-second resolution. On revalidation the client sends If-None-Match or If-Modified-Since, and the origin answers 304 Not Modified with no body when nothing changed. ETag wins when both are present. A 304 saves bandwidth, not a round trip.
Vary and cache keys
Vary lists the request headers that change the response (Accept-Encoding, Accept-Language). Each distinct value becomes a separate entry, so Vary: Cookie or User-Agent fragments a cache into near-uselessness. CDNs build their own cache key (host, path, query, selected headers or cookies); anything that changes the response but is not in the key is a cache-poisoning or data-leak risk.
Targeted CDN headers
CDN-Cache-Control (RFC 9213) and provider-specific variants such as Cloudflare-CDN-Cache-Control let you give the CDN a long TTL while browsers get a short one. On Cloudflare, CDN-Cache-Control is forwarded downstream; Cloudflare-CDN-Cache-Control is consumed by Cloudflare only.
Purge strategies
You can purge a CDN but never a browser. Purge by URL for single files, by tag or surrogate key for "everything showing product 42", and by prefix or everything as a last resort. Prefer designs that need no purge: hashed asset URLs and short HTML lifetimes, with purge as an accelerator rather than the correctness mechanism.

IN YOUR STACK

TypeScript · Express 5 Server

import express from "express";
const app = express();

// Hashed build output: a year, and no revalidation on reload.
app.use("/assets", express.static("dist/assets", { maxAge: "1y", immutable: true }));

// HTML shell: store, but revalidate every time (ETag/Last-Modified give 304s).
app.use(express.static("dist", {
  setHeaders(res, file) {
    if (file.endsWith(".html")) res.setHeader("Cache-Control", "no-cache");
  },
}));

// Same response for everyone: browser and CDN may share it.
app.get("/api/products", async (req, res) => {
  res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
  res.vary("Accept-Language");
  res.json(await listProducts(req.acceptsLanguages()[0] ?? "en"));
});

// Per-user: browser only, and revalidate so logout or edits show up.
app.get("/api/me", requireUser, (req, res) => {
  res.set("Cache-Control", "private, no-cache");
  res.json(req.user);
});
  • res.send and res.json add a weak ETag by default (the etag app setting) and answer 304 when the request's If-None-Match matches, but the handler still runs: you save bytes, not database work.
  • express.static defaults to maxAge 0 with ETag and Last-Modified on. In Fastify, reply.header("Cache-Control", ...) does the same job per route.
  • requireUser and listProducts are app-level helpers.
Express 5 documentation (opens in new tab)

nginx · nginx Server

server {
    root /srv/app/dist;
    etag on;  # default; static files also get Last-Modified

    location /assets/ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
        try_files $uri =404;
    }

    location = /index.html {
        add_header Cache-Control "no-cache" always;
    }

    location / {
        try_files $uri /index.html;  # SPA fallback inherits no-cache via /index.html
    }

    location /api/ {
        proxy_pass http://app;
        # Pass the app's Cache-Control through; do not override it here.
    }
}
  • add_header at a level disables inheritance of every add_header from outer levels, so a security header set in server {} silently disappears from a location that adds Cache-Control. nginx 1.29.3 adds add_header_inherit merge to change that.
  • Without always, add_header applies only to 200, 201, 204, 206, 301-304, 307 and 308, not to error pages.
  • Prefer explicit add_header Cache-Control over expires; expires also writes an Expires header and maps negative times to no-cache.
nginx documentation (opens in new tab)

TypeScript · Cloudflare Workers Cache API Server

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== "GET") return fetch(request);

    const url = new URL(request.url);
    url.searchParams.sort(); // normalise the cache key: ?b=2&a=1 == ?a=1&b=2
    const key = new Request(url.toString(), request);

    const cache = caches.default;
    const hit = await cache.match(key);
    if (hit) return hit;

    const origin = await fetch(request);
    if (!origin.ok || origin.headers.has("Set-Cookie")) return origin;

    const response = new Response(origin.body, origin);
    response.headers.set("Cache-Control", "public, max-age=300");
    ctx.waitUntil(cache.put(key, response.clone()));
    return response;
  },
};
  • caches.default is per data center: put and delete only affect the location that ran the Worker, and it does not participate in tiered caching. For global purges use the purge API (by URL, tag, host or prefix).
  • Responses with Set-Cookie are never stored, and stale-while-revalidate / stale-if-error are ignored by put and match.
  • Without a Worker, use Cache Rules: HTML and JSON are not cached by default, so mark the path eligible for cache and set edge and browser TTLs there.
Cloudflare Workers Cache API documentation (opens in new tab)

TypeScript · Next.js 16 App Router Server

// next.config.ts
import type { NextConfig } from "next";
export default { cacheComponents: true } satisfies NextConfig;

// lib/products.ts
import { cacheLife, cacheTag } from "next/cache";

export async function getProduct(id: string) {
  "use cache";
  cacheLife("hours");          // explicit lifetime; omitted = "default" profile
  cacheTag(`product:${id}`);
  return db.product.findUnique({ where: { id } });
}

// app/products/actions.ts
"use server";
import { revalidateTag, updateTag } from "next/cache";

export async function saveProduct(id: string, data: ProductInput) {
  await db.product.update({ where: { id }, data });
  updateTag(`product:${id}`);  // read-your-writes in this Server Action
}

// app/api/cms-webhook/route.ts (outside a Server Action)
export async function POST(req: Request) {
  const { id } = await req.json();
  revalidateTag(`product:${id}`, "max"); // stale-while-revalidate
  return Response.json({ ok: true });
}
  • "use cache" is a server-side data/render cache keyed on build ID, function ID and serialized arguments, separate from HTTP Cache-Control. It cannot read cookies() or headers(); pass those values in as arguments.
  • The default profile is stale 5 min (client), revalidate 15 min (server), never expires. revalidateTag(tag) with one argument is deprecated; pass "max" or { expire: 0 }.
  • Hashed files under /_next/static always get public, max-age=31536000, immutable and cannot be overridden; the headers() config can set Cache-Control on other routes.
Next.js 16 App Router documentation (opens in new tab)

TypeScript · Browser fetch() Client

// Normal HTTP semantics: fresh hit, conditional request when stale, or network.
const products = await fetch("/api/products");

// Always revalidate, even if fresh (cheap with ETag: usually a 304).
const me = await fetch("/api/me", { cache: "no-cache" });

// Bypass the cache and do not store the result (one-time secrets).
const token = await fetch("/api/one-time-token", { method: "POST", cache: "no-store" });

// Bypass the lookup but store the new response (after a known change).
await fetch("/api/products", { cache: "reload" });

// Offline-first: any stored copy, even stale; network only on a miss.
const config = await fetch("/config.json", { cache: "force-cache" });

// Cache only, never the network. Requires mode "same-origin".
try {
  const cached = await fetch("/config.json", { cache: "only-if-cached", mode: "same-origin" });
  if (cached.status === 504) showOfflineNotice(); // miss
} catch {
  showOfflineNotice(); // Chrome rejects a miss with a TypeError instead
}
  • These modes only control the browser HTTP cache. A CDN in front still applies its own rules; to force an origin hit you need a different URL or a CDN bypass.
  • In Next.js server code fetch() accepts the same cache option, but it controls the Next.js Data Cache, not a browser cache.
Browser fetch() documentation (opens in new tab)

Shell · curl Client

# 1. First request: note the validator and the cache headers.
$ curl -sI https://example.com/ | grep -iE '^(http|etag|last-modified|cache-control|age|cf-cache-status)'
HTTP/2 200
cache-control: no-cache
etag: "5f2a-19c3e1b2a40"
last-modified: Tue, 03 Nov 2026 09:12:44 GMT
cf-cache-status: DYNAMIC

# 2. Revalidate with the ETag: unchanged means 304 with no body.
$ curl -sI -H 'If-None-Match: "5f2a-19c3e1b2a40"' https://example.com/
HTTP/2 304
etag: "5f2a-19c3e1b2a40"
cache-control: no-cache

# 3. Same idea with the date validator.
$ curl -sI -H 'If-Modified-Since: Tue, 03 Nov 2026 09:12:44 GMT' https://example.com/

# 4. Check what the CDN did: Age > 0 and HIT mean the edge answered.
$ curl -sI https://example.com/assets/app.3f9c1a.js | grep -iE '^(age|cf-cache-status|cache-control)'
  • curl -I sends HEAD; some origins and CDNs treat HEAD differently, so confirm with curl -s -o /dev/null -D - for a real GET.
  • Cache status headers are vendor-specific (cf-cache-status, x-cache, x-vercel-cache); the standard Cache-Status header (RFC 9211) is less widely sent.
  • If a 304 never appears, look for an ETag that changes on every response (per-instance generation, compression variants) or a proxy that strips validators.
curl documentation (opens in new tab)

WHERE IT BITES

  • Caching index.html for a long time. Browsers keep running the old HTML, which references asset hashes that a later deploy deleted, so users get 404s on chunks. Keep old hashed assets around for a while after deploy as well.
  • Putting personalised responses behind a shared cache with public or a broad edge rule. One user's account page is then served to the next visitor; mark them private and make sure the CDN bypasses on session cookies.
  • Using no-cache when you meant no-store. no-cache still writes the response to disk and the back/forward cache may show it; sensitive pages need no-store.
  • Combining s-maxage with stale-while-revalidate and expecting both. s-maxage implies proxy-revalidate, and Cloudflare documents that it disables SWR and stale-if-error; use CDN-Cache-Control for the edge TTL instead.
  • Vary: Cookie or Vary: User-Agent on otherwise public responses, which multiplies cache entries and drops the hit ratio to near zero. Normalise the input into a small header (or the URL) and vary on that.
  • Treating purge as instant and global. Purges propagate, the Workers Cache API is per data center, and browsers cannot be purged at all, so a mistaken max-age=31536000 on a mutable URL lasts a year.

CLOSE THE AI. EXPLAIN THIS.

After a deploy, some users see a blank page and the console shows a 404 for /assets/index.8c1d.js. Trace which cache served the stale HTML, which header allowed it, and what you would change for HTML, hashed assets and the CDN.

SOURCES

Explainer reviewed