Web Development Performance

Cache-Control Headers That Actually Work

Published

no-cache does not mean "do not cache", and that single misreading is behind a huge share of broken caching setups. Here is what each directive really does, the two-tier strategy that works for almost every site, and how to verify it with one curl command.

Caching is the closest thing web performance has to free money. A cache hit is a request that never leaves the user's machine: no network, no server, no database, nothing to optimize because nothing happened. And yet most sites either cache nothing and pay for every byte repeatedly, or cache everything and then need users to hard-refresh to see a fix. Both failures come from the same place, which is that Cache-Control uses words that mean something slightly different from what they look like.

The one that trips up everybody

Start here, because this misunderstanding causes more trouble than the rest of the header combined:

no-cache   ->  You MAY store this. You MUST check with
               the server before reusing it.

no-store   ->  Do not write this to disk or memory at all.
               Ever.

no-cache is not "do not cache." It means "cache it, but revalidate every time." The response gets stored, and on the next request the browser asks the server whether its copy is still good. If it is, the server replies 304 Not Modified with no body, and the browser reuses what it has. You still pay a round trip, but you do not re-download the payload.

no-store is the real "do not cache." Use it for genuinely sensitive responses — a page showing someone's bank balance, a one-time token. Using it on your CSS because you wanted freshness is just switching caching off.

The full vocabulary

Cache-Control directives worth knowing
DirectiveWhat it does
max-age=NFresh for N seconds from when the response was generated. Relative, so no clock-skew problems.
s-maxage=NSame, but only for shared caches (CDNs, proxies). Overrides max-age for them.
publicShared caches may store it, even if the request was authenticated.
privateOnly the user's own browser may store it. Correct for anything per-user.
no-cacheStore it, but revalidate before every reuse.
no-storeDo not store it anywhere.
must-revalidateOnce stale, never serve it — revalidate or fail.
immutableDo not revalidate even if the user hits reload. Only safe for fingerprinted URLs.
stale-while-revalidate=NServe the stale copy instantly for up to N seconds while refreshing in the background.

Two footnotes. There is also an old Expires header that takes an absolute date; if both are present, Cache-Control wins, so you can ignore Expires in new work. And stale-while-revalidate is the quietly brilliant one: the user gets an instant response from cache, the cache updates itself behind the scenes, and the next visitor gets the fresh copy. Nobody waits.

Revalidation: ETag and Last-Modified

When a cached response goes stale, the browser does not start from scratch. It asks a conditional question:

First response from the server:
    HTTP/1.1 200 OK
    ETag: "7c4a8d09ca37"
    Cache-Control: no-cache

Next request from the browser:
    GET /styles.css
    If-None-Match: "7c4a8d09ca37"

Nothing changed:
    HTTP/1.1 304 Not Modified      <- no body at all

Last-Modified and If-Modified-Since work the same way with a timestamp instead of a hash. ETag is more precise, because it can tell that a file was touched but not actually changed.

A 304 saves the payload but not the round trip. On a mobile connection with 150 ms of latency, a page with 40 revalidating assets spends real time on nothing but "are you sure?" questions. Which is the whole argument for the strategy below.

The two-tier strategy

Almost every site converges on the same answer, and it hinges on one idea: make the URL change when the content changes. Then you never have to choose between freshness and caching, because a new version is a new URL.

Fingerprinted assets          Everything else
app.7f3c9a.css                /  /blog  /about
app.7f3c9a.js                 /api/*
logo.4b1e88.svg
      |                              |
      v                              v
cache for a year,             revalidate every time
never revalidate              (cheap: usually a 304)

max-age=31536000,             no-cache
immutable

The hash in the filename is what makes the aggressive half safe. Ship a change, the hash changes, the HTML points at a URL nobody has ever cached, and every user gets it immediately. No hard refresh, no cache-busting query strings, no support ticket that starts with "have you tried clearing your cache."

immutable is the finishing touch. Without it, a user pressing reload triggers revalidation on every asset even though they are all still valid. With it, the browser does not bother asking. This is why it is only safe on fingerprinted URLs — on a plain app.css it means users can be stuck with a stale file for a year and you have no way to reach them.

Apache

<IfModule mod_headers.c>
  # Fingerprinted build output only.
  <FilesMatch "\.[0-9a-f]{8,}\.(css|js|woff2|svg|png|jpg)$">
    Header set Cache-Control "public, max-age=31536000, immutable"
  </FilesMatch>

  # HTML always revalidates.
  <FilesMatch "\.(html|php)$">
    Header set Cache-Control "no-cache"
  </FilesMatch>
</IfModule>

<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/css \
    application/javascript application/json image/svg+xml
</IfModule>

Note the FilesMatch pattern: it requires a hex hash in the filename, so a stray styles.css cannot accidentally inherit the one-year policy. That specificity is deliberate. A broad \.(css|js)$ rule with immutable is one of the easiest ways to ship a bug you cannot recall.

Wrapping the blocks in <IfModule> matters on shared hosting, where mod_headers is not guaranteed. Without the guard, a missing module is a 500 on every request rather than a silently skipped rule. Our .htaccess generator emits these blocks with the guards in place, and you can review the output before it goes near a server.

nginx

location ~* "\.[0-9a-f]{8,}\.(css|js|woff2|svg|png|jpg)$" {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

location / {
    add_header Cache-Control "no-cache";
}

gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;

One nginx quirk worth internalizing: add_header in a nested block replaces the inherited set rather than adding to it. If headers vanish when you add a location block, that is why.

Do not forget Vary

If a response differs based on a request header, shared caches need to know, or they will serve the wrong variant to the wrong person.

Vary: Accept-Encoding

That is the common one: it stops a proxy from handing a gzipped body to a client that did not ask for compression. If you vary output by Accept-Language or by a device header, list those too. And be sparing — every value you add multiplies the number of cache entries, so Vary: User-Agent effectively disables shared caching.

Verify, do not assume

Config files lie. A proxy, a CDN, or your framework's default middleware may be rewriting what you set. Ask the actual response:

curl -sI https://example.com/assets/app.7f3c9a.css \
  | grep -iE 'cache-control|etag|expires|vary'

Then check that revalidation works end to end by replaying the ETag you got back:

curl -sI https://example.com/ \
  -H 'If-None-Match: "7c4a8d09ca37"' | head -n 1
# want: HTTP/2 304

And in DevTools, the Network panel's Size column is the fastest read of all: (disk cache) means you never touched the network, 304 means you paid a round trip to learn nothing changed, and a byte count means you downloaded it all over again. One glance down that column after a reload tells you whether your headers are doing their job.

The summary

  • Fingerprint your build output, then cache it for a year with immutable.
  • Send HTML no-cache so it revalidates cheaply and deploys are visible immediately.
  • Use private for anything user-specific and no-store only for genuinely sensitive responses.
  • Add stale-while-revalidate where a slightly old response is fine, which is most places.
  • Confirm with curl -sI, because what you configured and what you serve are two different facts.

More reading