Back to blog
Jul 2026·4 min readAI-generated

Understanding HTTP Caching Headers

#http#performance#web

Most performance problems people try to solve with a CDN or a caching layer are actually solved for free by three HTTP headers, set correctly, that most apps get wrong by accident rather than by choice. Cache-Control, ETag, and Last-Modified control whether a browser re-fetches a resource, re-validates it cheaply, or serves it instantly from disk — and the difference between those three outcomes is entirely in how you configure them.

Cache-Control: the header that matters most

Cache-Control tells the browser (and any proxy or CDN in between) how long a response can be reused without contacting the server again.

Cache-Control: max-age=3600

This means: for the next 3600 seconds, don't even ask the server — reuse the cached copy. No request goes out at all during that window, which is the fastest possible outcome, faster than any server-side optimization, because there's no round trip.

Common directives:

  • max-age=<seconds> — how long the response is fresh, from a browser cache's perspective.
  • public — allowed to be cached by shared caches (CDNs, proxies), not just the browser.
  • private — only the browser may cache it; a shared CDN in front of your app must not.
  • no-cache — despite the name, this does allow caching, but forces revalidation with the server on every use (see ETag below). It's no-store you want for "never cache this."
  • no-store — don't cache this at all, anywhere. The right choice for responses containing sensitive per-user data.
Cache-Control: private, no-store          # a user's account settings page
Cache-Control: public, max-age=31536000   # a hashed, versioned JS bundle
Cache-Control: no-cache                   # an HTML page you want revalidated every load

Why static assets should use a very long max-age

If your build pipeline outputs filenames with a content hash — app.a3f9c1.js instead of app.js — the filename itself changes whenever the content does. That means it's safe to cache that specific file forever, because a new version simply has a new URL:

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

immutable is a signal that goes further than max-age: it tells the browser not to even revalidate this resource on a hard refresh, because the content genuinely cannot change under this URL. This only makes sense on hashed, versioned filenames — never on a plain app.js that gets overwritten in place, or every deploy would be invisible to already-loaded browsers.

Revalidation: cheaper than a full re-download

For resources that do change but you still want cached, max-age isn't the whole story — you also want the browser to check "has this actually changed?" cheaply instead of re-downloading blindly. That's what ETag and Last-Modified are for.

app.get('/api/report/:id', async (req, res) => {
  const report = await getReport(req.params.id);
  const etag = `"${hashOf(report)}"`;

  res.set('ETag', etag);
  res.set('Cache-Control', 'no-cache');

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end(); // not modified — no body sent
  }

  res.json(report);
});

The browser sends back the ETag it was given via the If-None-Match header on the next request. If it still matches, the server replies 304 Not Modified with no body at all — the browser reuses its cached copy. This is why no-cache isn't "don't cache": it's "cache it, but check first," and that check is far cheaper than re-sending the whole response.

Last-Modified works the same way with a timestamp instead of a hash, sent back as If-Modified-Since:

res.set('Last-Modified', report.updatedAt.toUTCString());

if (req.headers['if-modified-since'] === report.updatedAt.toUTCString()) {
  return res.status(304).end();
}

Prefer ETag when you can compute a cheap hash of the response — it's precise (any content change invalidates it) where Last-Modified only has second-level granularity and depends on your data actually tracking modification times correctly.

Stacking them: a realistic API response

Cache-Control: private, max-age=60, must-revalidate
ETag: "9f8c3a"

This says: reuse the cached copy for up to 60 seconds without asking at all, then after that window, revalidate with If-None-Match before either serving a 304 or fetching fresh data. It's a good default for authenticated API responses that change occasionally but not on every request — the client isn't hammering the server, but also isn't stuck with minutes-old data.

The mistake that causes "why won't this update" bugs

Setting Cache-Control: public, max-age=31536000 on a plain, non-hashed filename is the single most common cause of "I deployed a fix and users still see the old version." The browser has no way to know the content changed — the URL is identical, and you told it explicitly not to ask for a year. The fix isn't a shorter max-age; it's giving the file a content hash in its filename so a real change produces a real new URL, and reserving long max-age exclusively for URLs that work that way.

Takeaway

Cache-Control decides whether the browser even makes a request. ETag/Last-Modified decide, when it does ask, whether the server can answer with a nearly-free 304 instead of resending the body. Use long, immutable caching only on content-hashed URLs; use short max-age plus revalidation for everything that can change under a stable URL; use no-store for anything sensitive that should never be written to a cache at all.

NewerReflows and Repaints: Why Your Page Feels JankyOlderA Practical Guide to API Pagination