Skip to main content

The Cache-Control Header Explained: A Practical Guide to HTTP Caching

A hands-on guide to the Cache-Control header and its directives — max-age, no-cache, no-store, public, private, immutable — and how to cache static assets safely.

Published By Li Lei
#http #caching #performance #web-development

The Cache-Control Header Explained: A Practical Guide to HTTP Caching

A single response header decides whether a returning visitor downloads your 280 KB JavaScript bundle again or reads it from disk in zero milliseconds. That header is Cache-Control, and most of the confusion around it comes down to a handful of directives that look similar but behave very differently. This guide walks through what each one does, how to cache static assets versus HTML, and why content-hashed filenames let you cache a file for an entire year without ever serving stale code.

What the Cache-Control header actually controls

Cache-Control is a comma-separated list of directives sent on an HTTP response. Each directive answers one question about how the response may be stored and reused: Can it be cached at all? By whom? For how long? Must it be checked again before reuse? You combine the directives you want in a single header value:

Cache-Control: public, max-age=3600

That line means "any cache, including a shared CDN, may store this response and serve it without contacting the origin for one hour." The browser and any proxy in between read those instructions and act on them. If you get the directives wrong, you either re-download everything on every visit (slow) or pin users to a stale copy they can't escape (worse).

max-age, no-cache, and no-store

These three are where most mistakes live, so it's worth being precise.

max-age=N sets freshness in seconds. A response is considered fresh for N seconds after it's stored, and during that window the cache serves it directly with no network round trip. max-age=3600 is one hour, max-age=86400 is one day, max-age=31536000 is one year. The unit is always seconds — a bare integer with no suffix — which is exactly the trap people fall into when they read max-age=3600 as "3600 minutes."

no-cache does not mean "do not cache." It means the browser may store the response but must revalidate with the origin before reusing it. The cache sends a conditional request (using an ETag or Last-Modified validator); if nothing changed, the origin returns a tiny 304 Not Modified and the stored body is reused. You pay for one cheap round trip and skip re-downloading the body.

no-store is the strict one. It forbids writing the response to any cache or disk at all. Every request goes to the origin and gets a full fresh response. This is what you want for a bank balance, an account settings page, or a one-time token — content that should never linger anywhere.

The difference between the last two trips up almost everyone: reach for no-cache when freshness matters but cheap revalidation is fine, and no-store only for genuinely sensitive content.

public, private, and immutable

public tells shared caches — CDN edges, corporate proxies — that they may store the response. private restricts storage to the end user's browser only, which you want for anything tied to a logged-in session so a proxy never hands one user's response to another. Setting both is a contradiction the response can't honor.

immutable is a promise: while the response is fresh under its max-age, the body will never change. With that guarantee, the browser skips even the conditional revalidation request on a reload — it doesn't ask "did this change?" because you've told it the answer is no. immutable is only meaningful alongside a long max-age; on its own it has no freshness window to apply to.

Static assets versus HTML: two opposite policies

Here's the key insight that makes everything click. Your static assets and your HTML pages want opposite caching policies, and the reason is whether the URL changes when the content does.

For HTML, the URL is stable — /pricing is always /pricing — but the content changes. So you can't cache it for long, or visitors get a stale page. Use no-cache so the browser keeps a copy but revalidates each time, returning a cheap 304 when nothing changed:

Cache-Control: no-cache

For static assets built with a content hash in the filename — app.4f3a1c.js, styles.9b2e07.css — a new build produces a new URL. The old URL is retired the moment the content changes, so the cached copy at the old URL can never go stale. That's what makes the aggressive policy safe:

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

A year of caching, served from disk with no revalidation, because the filename guarantees a changed file gets a changed URL.

Worked example

Say a page loads app.4f3a1c.js. The two responses your server should send:

# The HTML document — revalidate every time
GET /pricing
Cache-Control: no-cache

# The hashed JS bundle — cache for a year, never revalidate
GET /assets/app.4f3a1c.js
Cache-Control: public, max-age=31536000, immutable

When you ship a new build, the bundle becomes app.8d7f2b.js and the HTML — which you revalidated and got fresh — points at the new filename. The browser has never seen that URL, so it fetches it once and caches it for a year. No stale code, no manual cache busting, no purge step.

Why hashed filenames let you cache forever

The unhashed case is the failure mode worth burning into memory. If your asset URL is stable — /js/app.js with no hash — and you set max-age=31536000, immutable, you've told every browser to never check that URL again for a year. Ship a bug fix and your returning visitors run the broken version until the cache expires or they hard-refresh. You can't reach them.

The content hash fixes this structurally. Because the filename is derived from the bytes, you literally cannot have a "new version of the same URL" — a new version is a new URL. That's why the year-long immutable cache is safe with hashing and dangerous without it. The mistake isn't the long max-age; it's the long max-age on a URL that doesn't change when the content does.

My own footgun

I once shipped a CSS file with max-age=31536000 on a stable /css/main.css path — no hash — because I'd copied the directive from a "cache everything forever" snippet without thinking about the filename. A week later I pushed a layout fix and watched the staging build look perfect while production stayed broken for anyone who'd visited before. The fix was correct; the cache was just holding the old file hostage. I added content hashing to the build that afternoon and have never set a long max-age on a stable URL since. The directive was fine — the URL strategy was the bug.

Get the header right the first time

The directives are simple individually but easy to combine wrong: no-store paired with a max-age that gets silently ignored, immutable with no max-age to anchor it, public and private fighting over the same response. Rather than memorize the legal combinations, tick the directives you want in the Cache-Control Builder — it assembles the header in conventional order, converts days and hours into seconds for you, warns about contradictions the moment they appear, and copies out the raw header line plus Nginx and Apache directives. Once your headers are set, the HTTP Security Header Auditor checks the rest of your response headers so caching isn't the only thing you tightened.

Caching is one of the few performance wins that costs nothing at runtime and helps every returning visitor. Get the two policies right — revalidate HTML, cache hashed assets for a year — and most of the work is done.


Made by Toolora · Updated 2026-06-13