Web

HTTP Cache-Control Explained: max-age, no-cache, ETags

By Nawawi JamiliPublished 8 min read

Most caching problems I have debugged were caused by one header: Cache-Control. Set it too loosely and visitors see yesterday's CSS after a deploy. Set it too strictly and every page view downloads the same 300 KB of JavaScript again. The directives are short, but several of them mean something different from what their names suggest.

Rather than quote the specification, I wrote a tiny Node.js server that sends the four header patterns I actually use. Then I tested it with curl. Everything below ran on 127.0.0.1 with Node 18 and curl 7.61.

The test server

There are four routes. The first is an HTML page that must always be checked. The second is a fingerprinted JavaScript file that never changes. The third is a blog index that a CDN may cache longer than a browser. The fourth is a private JSON response that nobody should store. Every cacheable response gets an ETag (a hash of the body) and a Last-Modified date, and the server answers conditional requests with 304 Not Modified.

javascript
// Tiny cache-header demo server: node server.js, then curl 127.0.0.1:8732
const http = require('http');
const crypto = require('crypto');
const lastModified = new Date('2026-09-18T05:00:00Z');
const routes = {
  '/': {
    body: '<!doctype html><script src="/assets/app.3f9a1c7e.js"></script>\n',
    type: 'text/html; charset=utf-8',
    cache: 'no-cache',
  },
  '/assets/app.3f9a1c7e.js': {
    body: 'console.log("hello");\n',
    type: 'text/javascript',
    cache: 'public, max-age=31536000, immutable',
  },
  '/blog/': {
    body: '<h1>Latest posts</h1>\n',
    type: 'text/html; charset=utf-8',
    cache: 'public, max-age=60, s-maxage=600',
  },
  '/account': {
    body: '{"user":"awie","balance":42}\n',
    type: 'application/json',
    cache: 'private, no-store',
  },
};
http.createServer((req, res) => {
  const r = routes[req.url];
  if (!r) { res.writeHead(404); return res.end('not found\n'); }
  const etag = '"' + crypto.createHash('sha1').update(r.body)
    .digest('hex').slice(0, 16) + '"';
  const headers = { 'Content-Type': r.type, 'Cache-Control': r.cache };
  if (!r.cache.includes('no-store')) {
    headers['ETag'] = etag;
    headers['Last-Modified'] = lastModified.toUTCString();
  }
  const inm = req.headers['if-none-match'];
  const ims = req.headers['if-modified-since'];
  // If-None-Match wins over If-Modified-Since when both are sent
  const fresh = headers['ETag'] && (inm !== undefined
    ? inm.split(/\s*,\s*/).includes(etag)
    : ims !== undefined && new Date(ims) >= lastModified);
  if (fresh) { res.writeHead(304, headers); return res.end(); }
  headers['Content-Length'] = Buffer.byteLength(r.body);
  res.writeHead(200, headers);
  res.end(req.method === 'HEAD' ? undefined : r.body);
}).listen(8732, '127.0.0.1', () => console.log('listening on 8732'));
terminal
$ node server.js
listening on 8732

max-age: how long a response stays fresh

A cached response is either fresh (use it without asking the server) or stale (ask first). max-age=N says the response is fresh for N seconds after it was generated. Here is the blog index:

terminal
$ curl -sI http://127.0.0.1:8732/blog/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=600
ETag: "4a3f7917318c73de"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Content-Length: 22
Date: Sat, 17 Oct 2026 15:48:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

A browser that fetches /blog/ will reuse its copy for 60 seconds without any network request at all. After that it revalidates (more on that below). The public keyword explicitly allows shared caches, such as a CDN or a corporate proxy, to store the response. private restricts it to the end user's browser.

s-maxage: a separate lifetime for shared caches

s-maxage=600 applies only to shared caches and, for them, overrides max-age. So the CDN keeps /blog/ for ten minutes while each browser keeps it for one. That split is useful: you can purge a CDN when you publish, but you cannot purge a visitor's browser. Short browser lifetimes and longer edge lifetimes give you most of the speed, and still let you fix mistakes quickly.

no-cache is not no-store

This is the pair people mix up. no-cache does not mean "do not cache". It means "you may store this, but you must check with the server before every use". no-store is the one that means "do not keep a copy anywhere".

terminal
$ curl -sI http://127.0.0.1:8732/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
ETag: "dcdb78113c0d4c15"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Content-Length: 63
Date: Sat, 17 Oct 2026 15:42:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5

$ curl -sI http://127.0.0.1:8732/account
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, no-store
Content-Length: 29
Date: Sat, 17 Oct 2026 15:48:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

no-cache is what I want for HTML entry points: the browser keeps the page, asks "has it changed?" on each visit, and usually gets a tiny 304 back. no-store is for responses with personal or sensitive data, such as account pages, API responses with tokens, or anything you would not want left on a shared computer. It costs a full download every time, so do not use it as a general "make caching go away" switch.

max-age=0 behaves much like no-cache in practice. But a cache is allowed to serve a stale copy in some situations, for example when the origin is unreachable. You can stop this by also adding must-revalidate. If you mean "always check", say no-cache.

Revalidation: ETag, Last-Modified and 304

When a cached copy is stale (or marked no-cache), the browser does not simply download it again. It sends a conditional request carrying what it already has: the ETag in If-None-Match, the date in If-Modified-Since. If nothing changed, the server replies 304 Not Modified with no body, and the browser uses its copy.

terminal
$ curl -si -H 'If-None-Match: "dcdb78113c0d4c15"' http://127.0.0.1:8732/
HTTP/1.1 304 Not Modified
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
ETag: "dcdb78113c0d4c15"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Date: Sat, 17 Oct 2026 15:48:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

No Content-Length, no body. Send an ETag that does not match, which is what happens after a deploy changes the page, and the full response comes back:

terminal
$ curl -si -H 'If-None-Match: "0000000000000000"' http://127.0.0.1:8732/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Cache-Control: no-cache
ETag: "dcdb78113c0d4c15"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Content-Length: 63
Date: Sat, 17 Oct 2026 15:48:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!doctype html><script src="/assets/app.3f9a1c7e.js"></script>

Date-based validation works the same way, with second-level precision:

terminal
$ curl -si -H 'If-Modified-Since: Fri, 18 Sep 2026 05:00:00 GMT' http://127.0.0.1:8732/blog/
HTTP/1.1 304 Not Modified
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=600
ETag: "4a3f7917318c73de"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Date: Sat, 17 Oct 2026 15:48:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'If-Modified-Since: Thu, 17 Sep 2026 00:00:00 GMT' http://127.0.0.1:8732/blog/
200

When a client sends both headers, the ETag check takes precedence, which is what my server does too. ETags are the more reliable validator: a file can change twice within one second, or be rewritten with identical content and a new timestamp. And the no-store route has no validator at all, so a conditional request there always gets a 200:

terminal
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'If-None-Match: "dcdb78113c0d4c15"' http://127.0.0.1:8732/account
200

A trick for scripts: capture the ETag once and reuse it to check whether a remote file changed, without downloading it.

terminal
$ etag=$(curl -sI http://127.0.0.1:8732/ | tr -d '\r' | awk -F': ' 'tolower($1)=="etag"{print $2}')
$ curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $etag" http://127.0.0.1:8732/
304

The tr -d '\r' matters: HTTP header lines end in CRLF, and without it the ETag carries an invisible carriage return that never matches. Newer curl releases (7.68 and later) can do this for you with --etag-save and --etag-compare. The 7.61 on my test machine is too old.

One more thing worth knowing: some responses have Last-Modified but no Cache-Control or Expires at all. For these, the HTTP caching standard allows browsers to guess a freshness lifetime (heuristic caching). This is commonly a fraction of the time since the last modification. That is how a site with no caching headers can still serve stale pages for hours. Always send an explicit Cache-Control.

immutable and fingerprinted URLs

The best caching strategy for CSS, JavaScript and images is to put a content hash in the file name, as build tools do, and cache the file for a year:

terminal
$ curl -sI http://127.0.0.1:8732/assets/app.3f9a1c7e.js
HTTP/1.1 200 OK
Content-Type: text/javascript
Cache-Control: public, max-age=31536000, immutable
ETag: "028d456afa097280"
Last-Modified: Fri, 18 Sep 2026 05:00:00 GMT
Content-Length: 22
Date: Sat, 17 Oct 2026 15:42:24 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Since the URL changes whenever the content does, a cached copy at a given URL can never be wrong, so there is no reason to revalidate it ever. immutable tells the browser exactly that: even when the user presses reload, do not send a conditional request for this file. Not every browser acts on it, but it does no harm where it is ignored.

The two halves only work together. The fingerprinted assets get a year plus immutable. The HTML that references them gets no-cache. After a deploy the browser revalidates the HTML, receives a page pointing to app.8b2e04d1.js, and fetches the new file. Never put a long max-age on a URL whose content changes in place, like /css/style.css: you cannot take it back, and some visitors will run old CSS until their cache expires.

What a CDN like Cloudflare does with these headers

CDNs differ in the details and their defaults change, so check your provider's documentation. For Cloudflare, these are the well-documented behaviours I rely on:

  • By default it caches by file extension: static assets such as .js, .css and images are eligible, while HTML is not cached unless you configure a cache rule for it.
  • For content it does cache, it follows the origin's Cache-Control by default, and s-maxage is honoured as the edge lifetime. private and no-store responses are not cached.
  • Dashboard settings such as Edge Cache TTL and Browser Cache TTL can override what your origin sends. This is a common reason why the headers you see in the browser are not the ones your server set.
  • It adds a CF-Cache-Status response header (HIT, MISS, EXPIRED, DYNAMIC and others), which is the first thing to check when you are not sure whether the edge served a request.
  • When it modifies a response, for example by compressing it, it may weaken a strong ETag (W/"..."). Weak ETags still work for If-None-Match.

After a deploy, purge the HTML (or everything) at the CDN. Fingerprinted assets do not need purging. That is the point of fingerprinting them.

My defaults

ContentCache-Control
Fingerprinted assets (app.3f9a1c7e.js)public, max-age=31536000, immutable
HTML pagesno-cache, or public, max-age=60, s-maxage=600 behind a CDN you purge
Unversioned assets (/favicon.ico)public, max-age=86400
Personal or sensitive responsesprivate, no-store

The authoritative reference is RFC 9111, HTTP Caching. It is readable, and section 5.2 lists every directive.