HTTP Caching Headers: A Practical Reference
Every HTTP response your server sends carries instructions about its own reuse: cache me for a year, check with me first, never store me at all. Browsers, CDNs, and corporate proxies obey these instructions faithfully — which is exactly the problem, because most sites ship instructions nobody ever consciously wrote. The defaults come from a web server config copied years ago, a plugin's guess, or a CDN's fallback behavior, and the results range from "visitors re-download the logo on every page" to "customers see yesterday's prices for a week."
Caching headers are one of the few performance topics where reading a reference genuinely settles the matter. This is that reference: the handful of headers that matter, what each directive actually promises, and copy-ready recipes for the three kinds of responses every site serves.
The mental model: freshness and validation
All of HTTP caching reduces to two questions a cache asks about a stored copy:
- Is this copy still fresh? If yes, use it without asking anyone. Zero network traffic. This is the fast path, governed mainly by
Cache-Control: max-age. - If it's stale, is it still valid? The cache asks the server: "I have version X — still good?" The server replies either
304 Not Modified(a tiny response; keep using your copy) or sends the new version. This is revalidation, governed byETagandLast-Modified.
Freshness saves the whole round trip; validation saves the download but still costs a round trip. Good caching strategy is deciding, per resource type, how long the free ride (freshness) should last and what happens after it ends.
Cache-Control: the header that runs the show
Cache-Control carries comma-separated directives. The ones you'll actually use:
| Directive | What it really means |
|---|---|
max-age=3600 | Fresh for 3600 seconds from the moment it was fetched. Within that window, no network request happens at all. |
no-cache | Misleading name of the decade: it does NOT mean "don't cache." It means "store it, but revalidate with the server before every use." You always get a round trip, but often only a 304. |
no-store | The one that actually means don't cache. Nothing is written to disk. For genuinely sensitive responses. |
private | Browser may cache; shared caches (CDNs, proxies) may not. For per-user content. |
public | Shared caches may store it, even some responses they'd normally skip. Usually implied; rarely needed explicitly. |
s-maxage=86400 | A separate freshness lifetime for shared caches only. Lets you tell your CDN "hold for a day" while browsers get a shorter max-age — handy because you can purge your CDN but not your visitors' browsers. |
immutable | "This URL's content will never change, don't even revalidate on reload." Only safe with versioned filenames. |
stale-while-revalidate=60 | After freshness expires, keep serving the stale copy for up to 60 more seconds while fetching the update in the background. Users never wait; content self-heals. Quietly one of the best directives available. |
must-revalidate | Once stale, never serve without a successful revalidation — no leniency. For correctness-critical resources. |
Common combinations decoded: max-age=0, must-revalidate ≈ "always check first" (functionally close to no-cache). private, no-cache = "browser may keep a copy for 304 efficiency, but always ask, and CDNs stay out of it" — a sane default for logged-in HTML.
Validators: ETag and Last-Modified
Revalidation needs a way to identify versions:
ETagis an opaque fingerprint (usually a hash) of the response. The browser sends it back asIf-None-Match; matching fingerprint gets a 304.Last-Modifiedis a timestamp doing the same job with less precision (one-second resolution, and file timestamps lie after deployments). Sent back asIf-Modified-Since.
Serve both if convenient; ETag wins when both are present. One operational gotcha worth knowing: on multi-server setups, default ETags sometimes incorporate server-specific file metadata, so two backends generate different ETags for identical content — silently breaking revalidation behind a load balancer. If your 304 rate is mysteriously near zero, this is a prime suspect.
The recipes: three response types, three strategies
1. Static assets — cache forever, version the filename
Cache-Control: public, max-age=31536000, immutable
One year, immutable, done — on the strict condition that the filename changes when the content changes (app.9f3ab.js, logo.v2.png). The version-in-filename trick converts cache invalidation from a hard distributed-systems problem into a non-event: new content gets a new URL, and the old URL's eternal cache entry simply stops being referenced. Build tools and most WordPress optimization plugins handle the fingerprinting; WordPress core approximates it with ?ver= query strings, which most caches treat as part of the cache key — cruder, but it works.
What if you can't version filenames? Then a year is reckless. Drop to something you can tolerate being stale — max-age=86400 (a day) — and accept that emergency fixes to CSS will take up to a day to reach repeat visitors.
2. HTML pages — short or zero freshness, smart revalidation
HTML is where aggressive caching bites, because the URL can't change when content does — /pricing is /pricing forever. Browsers holding stale HTML is the classic "I updated the page but visitors see the old one" mystery, and unlike your CDN, you cannot purge people's browsers. Hence the asymmetric strategy:
- Browsers:
Cache-Control: no-cache(ormax-age=0, must-revalidate) — always revalidate. With ETags in place, unchanged pages cost a cheap 304, changed ones arrive immediately. - CDN, if you edge-cache anonymous pages: add
s-maxage=600, stale-while-revalidate=60— the CDN holds pages ten minutes (purgeable on publish), and the SWR window hides the refresh latency from whoever lands on an expired copy.
Combined: Cache-Control: no-cache, s-maxage=600, stale-while-revalidate=60. Instant corrections for browsers, near-total origin offload at the edge.
3. Personalized and sensitive responses — keep them out of shared caches
- Logged-in pages, carts, dashboards:
Cache-Control: private, no-cache. Theprivateis load-bearing — a shared cache storing one user's account page and serving it to another is a genuine data breach, and misconfigureds-maxageon personalized pages has caused exactly that at real companies. - Truly sensitive data (banking-grade, tokens, PII downloads):
Cache-Control: no-store. Nothing touches disk. - APIs: mostly
no-storeorprivate, no-cache; but read-heavy public endpoints (a product feed, a public stats endpoint) take short freshness beautifully —public, max-age=60, stale-while-revalidate=30can absorb enormous read traffic.
Auditing what you actually serve
Ten minutes in DevTools tells you where you stand:
- Open the Network tab, load a page, and click your main CSS file. Check
Cache-Controlin the response headers. A one-yearmax-agewith versioned filenames: healthy. A baremax-age=0or nothing at all: you're re-downloading assets constantly. - Reload the page. Assets showing "(from disk cache)" or "(from memory cache)" hit the fast path. A column full of 304s means you're validating when you could be trusting — usually a missing
max-age. A column full of 200s with full sizes means no caching at all. - Check an HTML page while logged in. If it lacks
private(orno-store) and you run a CDN that caches HTML, stop and fix that before anything else in this article. - If a CDN is in play, look for its status header (
cf-cache-status,x-cache) to see hits versus misses at the edge.
In WordPress land, these headers typically come from up to three layers — your web server config, a caching plugin, and your CDN — and the layers can disagree. The response the browser sees is the final word; audit that, not your intentions. When layers conflict, decide which one owns caching policy (usually: plugin or host for HTML, server config for static files, CDN respecting both) and silence the others.
A closing rule of thumb
If you remember nothing else: version your asset filenames and cache them for a year; revalidate your HTML always; mark personalized responses private; and reserve no-store for secrets. That single sentence outperforms the caching configuration of most of the web — and now you know precisely why each clause is there.
Photo: Miguel Á. Padriñán on Pexels