HTTP Caching Headers Guide: The 5 You Need, 10 You Don't

2026-04-06 · Nico Brandt

You searched “http caching headers,” got a 5,000-word reference doc listing every Cache-Control directive ever ratified, and still don’t know what to put in your nginx config. Most caching guides are written for the HTTP spec, not for your production app.

This one filters signal from noise. Five directives handle 95% of real-world caching. The rest are edge cases you’ll probably never touch — and one section most guides skip entirely: when caching makes things worse.

The 5 Directives That Actually Move the Needle

Out of 15+ Cache-Control directives, these five cover what a production app needs. The rest are noise, edge cases, or already implied by browser defaults.

max-age — the workhorse. One header, biggest performance win. Set it in seconds, and the browser won’t even ask your server for that resource until the timer expires. max-age=86400 means “keep this for a day.” For versioned static assets with content-hashed filenames, crank it to max-age=31536000 (one year). The filename changes on every build, so stale cache is impossible.

no-store — the kill switch. Nothing gets cached. Not by the browser, not by the CDN, not by any proxy in between. Use it for authenticated pages and sensitive data. No ambiguity, no exceptions.

no-cache — the most misunderstood directive in the spec. It does cache. It just revalidates every single time. The browser stores the response locally but asks the server “has this changed?” before using it. If the server says no (304), the browser uses the local copy without re-downloading the body. Perfect for HTML that changes unpredictably but benefits from conditional requests.

immutable — the hidden gem. Tells the browser “this file will never change at this URL.” Pair it with content-hashed filenames (app.a3f8c2.js) and the browser won’t even send a revalidation request on back/forward navigation. Eliminates those 304 round-trips that add up fast on pages with 30+ static assets.

stale-while-revalidate — the UX upgrade. Serves the cached (potentially stale) version immediately while fetching a fresh copy in the background. Users see instant loads. Content stays current by the next request. It’s the stale-while-revalidate pattern at the HTTP level — if you’ve used SWR or React Query, you already understand the tradeoff.

Everything else? must-revalidate does what browsers already do by default. proxy-revalidate is an edge case for shared caches you probably don’t control. no-transform prevents proxy modifications that almost never happen anymore. Pragma and Expires are legacy headers from HTTP/1.0 — Cache-Control overrides both.

Five directives. That’s the toolkit. But knowing them isn’t the same as knowing what to type — so let’s look at actual configs.

Real Headers for Real Apps

Three scenarios, three copy-paste-ready header sets.

Static assets with content hashing (JS, CSS, fonts, images where the filename includes a hash):

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

One year. Safe because the filename changes on every build. Understanding what’s in those bundles matters too—a javascript bundle analyzer helps you optimize before you cache. public lets CDNs cache it. immutable kills revalidation requests. This is the single highest-impact caching header most apps can set — and the one most apps leave on the table.

Fonts benefit from the same strategy when served from versioned URLs—font loading performance optimization covers the full approach.

HTML pages and server-rendered routes:

Cache-Control: no-cache
ETag: "a3f8c2d1"

Forces revalidation on every request. The ETag is a fingerprint — your server generates it from the response content. When the browser sends If-None-Match: "a3f8c2d1", the server checks whether the content changed. Same ETag? 304, no body transferred. Different? Full 200 with the new content. Fresh HTML without paying for a full download every time.

API responses behind authentication:

Cache-Control: no-store

No discussion. Cached authenticated responses are data leaks waiting to happen. If the response differs per user, no-store is the only safe option.

Bonus — CDN-specific TTL:

Cache-Control: no-cache, s-maxage=300

Browser always revalidates (no-cache). CDN caches for five minutes (s-maxage overrides max-age for shared caches). Useful when your CDN sits in front of an expensive origin server.

Clean configs. But here’s what most http caching headers guides won’t tell you: there are cases where all of this is the wrong move.

When Caching Makes Things Worse

Caching is a performance tool, not a default. If you can’t articulate what staleness costs for a given response, you haven’t thought it through.

Real-time data — stock tickers, live scores, chat messages. Caching adds staleness with zero benefit. Use no-store or skip Cache-Control entirely and rely on WebSocket or SSE. A cached stock price isn’t a performance win. It’s misinformation.

Personalized content behind auth — if the response changes per user, any shared cache layer becomes a data leak risk. A CDN that serves User A’s dashboard to User B isn’t a caching bug. It’s a security incident.

GDPR-sensitive responses — cached personal data is still stored data. Regulators don’t distinguish between “it’s in a database” and “it’s in the browser cache.” If the response contains PII, no-store is a compliance requirement, not an optimization choice.

Rapidly changing API responses where staleness causes bugs — inventory counts, booking availability, seat maps. Stale data here isn’t a minor inconvenience. It’s a support ticket from a customer who “bought” an item that was already gone.

These exceptions matter. But they also reveal where HTTP headers hit their limit — they’re binary. Cached or not. There’s no logic layer. For anything smarter, you need a different tool.

Where HTTP Headers Stop and Service Workers Start

HTTP caching is a light switch. On or off. Service workers give you a programmable dimmer.

Three service worker caching strategies, each solving a different problem:

Cache-first — serve from cache immediately, update in background. Ideal for app shells and static assets. The user sees something instantly. The fresh version loads for next time. If you’re already using immutable on hashed assets, cache-first in a service worker makes your app feel native-fast.

Network-first — try the network, fall back to cache. Use this for API data that should be fresh but needs offline resilience. The app always tries live data first. If the network fails, the last cached response keeps the UI functional instead of showing a blank screen.

Stale-while-revalidate (SW version) — same concept as the HTTP header, but with programmable control over what “stale” means and per-route logic that headers can’t express. You decide which routes get which strategy.

Workbox implements all three patterns in about 10 lines of config. It’s not a deep rabbit hole — it’s a practical tool.

The key insight: service workers and HTTP cache headers work together. HTTP headers control the browser’s built-in cache. Service workers add a programmable layer on top. Complementary, not competing.

One gotcha that trips people up: bfcache (back/forward cache) ignores your Cache-Control headers entirely. When a user hits the back button, the browser restores the full page from memory — including stale auth states and outdated cart counts. If your page has state that must be fresh, listen for the pageshow event and check event.persisted. Headers can’t solve this. It’s a browser restoration problem, and it affects INP too.

Armed with directives, configs, exceptions, and service worker patterns, you might think you’re covered. You’re close — but three silent mistakes can undermine all of it.

Three Mistakes That Silently Waste Your Cache Setup

These won’t throw errors. They’ll just quietly make your browser caching best practices useless.

Mistake 1: Setting max-age on HTML without cache-busting your assets. You update your CSS. Users see the old version because their cached HTML still references styles.css — the same filename. The fix: content-hash your assets (styles.a3f8c2.css), use no-cache on HTML so the browser always fetches the latest HTML pointing to the latest hashed filenames. If your bundle setup isn’t generating hashed filenames, fix that first.

Mistake 2: Using no-cache without ETags. no-cache tells the browser to revalidate. But revalidate against what? Without an ETag or Last-Modified header, the browser has no way to send a conditional request. Every “revalidation” becomes a full download. Most frameworks generate ETags automatically — but some CDNs strip them. Check yours.

Mistake 3: Caching API responses without a proper Vary header. If your API returns different content based on the Authorization header and you don’t set Vary: Authorization, a CDN might cache one user’s response and serve it to everyone. Setting Vary: * goes the other direction — it disables caching entirely. Be specific: Vary: Authorization or Vary: Accept-Encoding, Authorization.

Quick verification: Open Chrome DevTools, Network tab, check the “Size” column. You’ll see disk cache, memory cache, or a byte count. The first two mean caching is working. A byte count means the full response was transferred. Takes 10 seconds and tells you more than any config file.

The Bottom Line

You don’t need to memorize 15 directives. Five handle the real work.

Static asset with a hashed filename? max-age + immutable. HTML page? no-cache + ETag. Sensitive data? no-store. Content that tolerates brief staleness? stale-while-revalidate. Everything else is an edge case you’ll Google when you need it.

When HTTP headers aren’t enough — offline support, per-route logic, programmable freshness — service workers fill the gap. When you want to verify any of it, DevTools is a tab away.

Caching isn’t glamorous. But a well-cached site loads fast enough that your users never think about performance — which is exactly the point.