What the Cache-Control Header Does
Every HTTP response can carry a Cache-Control header, and it is the single most important line in web performance. It tells browsers, CDNs, and proxies three things: whether the response may be stored, where it may be stored, and how long it stays fresh. Get it right and repeat visits load in milliseconds from a cache near the user; get it wrong and users see stale prices, broken deploys, or — just as bad — nothing cached at all and a slow site.
The header is a comma-separated list of directives. The two you will use most are the scope (public, private, or no-store) and the freshness lifetime (max-age=N, in seconds). This tool's core function builds exactly that pair — cacheHeader(3600, 'public') produces public, max-age=3600 — and the UI layers on the real-world directives professionals add around it.
Public vs Private vs No-Store
The scope directive answers one question: who is allowed to keep a copy?
- public — anyone may cache it: the user's browser, the CDN, corporate proxies, all of them. Use for assets that are identical for every user: images, fonts, versioned JS/CSS.
- private — only the end user's browser may cache it, never a shared cache. Use for personalized responses: account pages, API responses containing user data.
- no-store — do not store it anywhere, ever. Use for sensitive data (banking, health) and for responses that must never be reused. Note:
no-cacheis not the same thing — it means 'store it, but revalidate with the server before every reuse.'
The classic bug is serving personalized HTML with public: one user's dashboard gets cached at the CDN and served to the next visitor. The classic opposite bug is serving static assets with private or no header at all, forcing every user to re-download your 2MB JavaScript bundle on every visit.
Max-Age: How Long Is Fresh?
| Content type | Typical max-age | Reasoning |
|---|---|---|
| Hashed static assets (app.a3f9c2.js) | 31536000 (1 year) + immutable | Filename changes when content changes — can never go stale |
| Images, fonts | 604800–2592000 (1 week–30 days) | Rarely change; long cache with versioned URLs |
| HTML pages | 0–3600 | Must stay fresh; often no-cache with revalidation |
| API responses (public data) | 60–3600 | Balance freshness vs load; use ETags for revalidation |
| API responses (personalized) | 0, private | Fresh per user; browser-only caching at most |
The golden rule of cache lifetimes: cache the immutable aggressively, keep the entry points fresh. Your fingerprinted assets (filenames containing a content hash) can be cached for a year with immutable because a changed file gets a new name — the old cached copy can never be wrong. Your HTML, which references those filenames, must be revalidated quickly so users pick up the new references. This two-tier strategy is how modern frameworks (Next.js, Vite, Create React App) achieve both instant repeat loads and safe deploys.
The Directives That Matter (Explainer)
must-revalidate
Once the cached copy goes stale, the cache must check with the origin server before serving it. Without this, some caches are allowed to serve stale content in edge cases (like when the origin is unreachable). Add it to anything where staleness has consequences — prices, inventory, account state.
immutable
A promise: this response body will never change during its freshness lifetime. Browsers take it seriously — Firefox and Chrome will not even revalidate on a force-reload for immutable resources. Only use it with content-hashed filenames; applying it to a mutable URL is how you ship a bug that users cannot clear.
stale-while-revalidate=N
The performance secret weapon: serve the stale copy immediately while fetching a fresh one in the background for next time. Users never wait; content stays reasonably fresh. Excellent for semi-static content like blog posts, docs, and marketing pages. Pair with a modest max-age: fresh for an hour, usable-while-revalidating for a day.
no-transform
Forbids intermediate caches from modifying the body — relevant when carriers or proxies compress or transcode images. Matters for image-heavy sites behind aggressive middleboxes.
s-maxage=N
Overrides max-age only for shared caches (CDNs). Lets you tell the CDN 'keep it for a day' while telling browsers 'keep it for an hour' — fine-grained control over the two cache layers.
CDN Notes: What Changes at the Edge
A CDN is a shared cache with opinions. Key things to know:
- Query strings: most CDNs treat
style.css?v=2as a different object fromstyle.css?v=1— useful for cache-busting, dangerous if your app generates random query strings (each becomes a separate, rarely-hit cache entry). - Vary: the
Varyheader tells caches that the response differs by request header (e.g.,Vary: Accept-Encoding). A missing Vary on compressed responses is a classic cause of 'works for me' encoding bugs. - Purge vs TTL: long TTLs are fine if your CDN supports instant purge on deploy. The modern pattern is long max-age + purge-on-deploy, not short max-age.
- Cache keys and cookies: if your CDN includes cookies in the cache key (some do by default), every logged-in user gets a separate cache entry and your hit rate collapses. Strip or ignore cookies for public assets.
Validation: ETags and Last-Modified
Freshness (max-age) is only half the story; validation is the other half. When a cached copy goes stale, the client can ask 'has it changed?' instead of re-downloading: the server answers with an ETag (a content fingerprint) or Last-Modified timestamp. If nothing changed, the server replies 304 Not Modified with an empty body — all the latency savings of caching with none of the staleness risk. The winning combination for HTML and APIs is short max-age (or no-cache) + ETag: always check, rarely download.
Common Cache-Control Mistakes
- Caching HTML for a year. Users get stuck on old deploys. HTML should be fresh; assets should be long-lived.
- Using no-cache when you mean no-store.
no-cachestill stores — it just revalidates. For truly sensitive data,no-store. - Forgetting immutable needs hashed filenames. Immutable on a mutable URL is a footgun.
- Private data served as public. Audit any response containing user data — one
publicon a dashboard endpoint is a breach. - No header at all. Without Cache-Control, browsers fall back to heuristic caching (often 10% of time since Last-Modified) — unpredictable and usually too short for assets, too long for HTML.
How to Verify Your Headers
Generate the header here, set it, then verify in the real world: open DevTools → Network tab → click a request → check the cache-control response header and the Size column ('memory cache' / 'disk cache' means it worked). For CDN behavior, check the age or x-cache headers your provider adds. Tools like WebPageTest and Google's PageSpeed Insights will flag missing or misconfigured caching as one of the highest-impact fixes — because it usually is.
Framework Defaults: What You Get Out of the Box
Modern frameworks have opinions about caching, and knowing them saves debugging. Next.js serves _next/static assets with public, max-age=31536000, immutable (hashed filenames — the gold standard) while pages default to short-lived or revalidated responses. Vite emits hashed asset filenames designed for the same year-long immutable treatment; you add the header in your server or CDN config. Nginx ships no Cache-Control by default — you write the add_header lines yourself, which is why generated snippets like this tool's nginx copy button exist. Cloudflare, Fastly, and AWS CloudFront respect your origin's Cache-Control but each add their own edge-TTL settings that can override it — always check whose TTL actually wins in your CDN's dashboard, because an edge rule of 'respect origin' versus 'override to 1 hour' changes everything.
The single most common production bug in this space is the hashed-asset HTML mismatch: assets cached for a year (correct) while the HTML referencing them is also cached for a day or more (incorrect), so deploys do not reach users until the HTML expires. The fix is always the same two-tier setup: immutable year-long caching for fingerprinted assets, and no-cache + ETag (or very short max-age) for HTML entry points.
Cache-Control and Privacy Regulations
Caching intersects with privacy law in ways developers underestimate. A public directive on a response containing personal data is not just a bug — under GDPR-style regimes it can be a reportable incident if a shared cache serves one user's data to another. Audit every endpoint that returns user-specific content and ensure it carries private or no-store; better yet, set these at the framework middleware level so no endpoint can forget. Cookie-based sessions add another trap: some CDNs include cookies in the cache key by default, silently personalizing every cached object and destroying hit rates. The correct setup strips cookies from cache keys for public assets and bypasses the cache entirely for authenticated HTML. When in doubt, private, max-age=0, must-revalidate is the conservative default for anything behind a login.
Debugging Cache Issues: A Checklist
- Check what was actually sent: DevTools → Network → response headers. Trust this over your config files.
- Check the Size column: 'memory cache' or 'disk cache' means the browser served it locally; a 200 with full download means it did not.
- Disable cache and compare: DevTools' 'Disable cache' checkbox isolates caching from application bugs.
- Look for the CDN's headers:
age,x-cache: HIT/MISS, orcf-cache-statusreveal the edge layer's decision. - Test with curl:
curl -sI -H 'Cache-Control: no-cache' URLshows origin headers without browser interference. - Verify purge worked: after purging, confirm MISS on first request and HIT on second — a purge that 'succeeds' but leaves stale copies is worse than no purge.