Back to Blog
Guide8 min read2026-07-13

HTTP Caching: Cache Assets Forever, Keep HTML Fresh

Cache-Control, ETag, and immutable explained properly — and the two-rule header strategy that makes deploys instant without stale pages.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Most sites need exactly two caching policies: hashed static assets cached for a year and never revalidated, and HTML that's always revalidated before use. That's it. The reason so many sites get it wrong is that the header directives don't mean what their names suggest — no-cache caches, must-revalidate doesn't mean "always revalidate", and immutable is only safe if your build tool cooperates.

Let's take the directives apart, then assemble the two-rule strategy.

How a browser decides to use its cache

Every cached response has a freshness lifetime, usually set by Cache-Control: max-age. While the response is fresh, the browser serves it from disk or memory without talking to your server at all — zero network. Once it's stale, the browser doesn't just throw it away: it sends a conditional request ("I have version X — still good?"). If the server answers 304 Not Modified, the cached copy is reused and only headers crossed the wire.

The important mental model: the expensive part of a revalidation is the round trip, not the bytes. A 304 on a high-latency mobile connection still costs a full RTT per resource. The goal of a good caching strategy is to eliminate round trips entirely for things that can't change, and keep exactly one cheap round trip for things that can.

Cache-Control directives that actually matter

  • max-age=N — the response is fresh for N seconds. This is the workhorse.
  • no-cache — the most misnamed directive in HTTP. It does *not* prevent caching. It means: store it, but revalidate with the server before every use. This is precisely what you want for HTML.
  • no-store — the real "don't cache". Nothing is written to disk. Reserve it for genuinely sensitive responses; using it site-wide makes every visit a cold visit.
  • private / publicprivate keeps shared caches (CDNs, corporate proxies) from storing per-user responses. Anything behind a login that varies by user should be private.
  • s-maxage=N — a freshness lifetime that applies only to shared caches, overriding max-age there. This is how you let a CDN cache HTML briefly while browsers still revalidate every time.
  • must-revalidate — despite the name, it changes nothing while a response is fresh. It only forbids a cache from serving the response *stale* (some caches will otherwise serve stale content when the origin is unreachable).
  • immutable — tells the browser the body will never change for this URL, so don't revalidate it even when the user hits reload. Only safe when the URL itself changes whenever the content does — which is exactly what content-hashed filenames give you.
  • stale-while-revalidate=N — serve the stale copy instantly and refresh it in the background. Great for resources where slightly-old is fine but slow is not.

ETag and conditional requests

An ETag is an opaque version identifier the server attaches to a response. On revalidation the browser sends it back in If-None-Match, and the server replies 304 if it still matches. Last-Modified/If-Modified-Since is the older, coarser fallback — it has one-second granularity and lies if you regenerate a file with identical content.

Two production pitfalls worth knowing:

  1. 1ETags computed from file metadata break behind load balancers. If two servers derive different ETags for byte-identical files (Apache historically mixed inode numbers into its default ETag), a client bouncing between servers never gets a 304. Make sure the ETag is derived from content only.
  2. 2Compression can change the ETag story. nginx, for example, weakens ETags (W/"...") on gzipped responses because the compressed bytes differ from the original. Weak ETags still work for revalidation, but if some layer strips them you silently lose 304s. If your HTML responses always come back 200, check this first.

The two-rule strategy

Here's the whole thing:

Rule 1 — hashed assets: cache forever.

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

Rule 2 — HTML: always revalidate.

Cache-Control: no-cache
ETag: "<content hash>"

Why it works: your HTML is the mutable pointer, and your assets are immutable values. Every modern bundler emits content-hashed filenames — Vite produces dist/assets/index-BQt4LMbZ.js, webpack has [contenthash], and Astro, Next.js and friends do the same. When you deploy, the new HTML references new filenames. Browsers fetch exactly the assets that changed and serve everything else from disk with zero round trips. Old cached assets are simply never referenced again; they age out on their own.

Deploys become instant and atomic from the user's perspective: one cheap HTML revalidation, then either "nothing changed" (304) or "here's the new pointer" (200 with new asset URLs).

In nginx:

location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
}

location / {
    add_header Cache-Control "no-cache";
    try_files $uri /index.html;
}

In Express:

app.use('/assets', express.static('dist/assets', {
  maxAge: '1y',
  immutable: true,
}))

app.get('*', (req, res) => {
  res.set('Cache-Control', 'no-cache')
  res.sendFile(path.join(__dirname, 'dist/index.html'))
})

Note the =404 in the nginx assets block: a missing hashed asset should be a hard 404, never a fallback to index.html — otherwise the browser caches an HTML page under a .js URL for a year. If you've ever seen Uncaught SyntaxError: Unexpected token '<' in production, this is usually how it happened.

Adding a CDN without changing the rules

The asset rule needs no modification — public, max-age=31536000, immutable is exactly what a CDN wants too, and versioned URLs mean you never need to purge them.

HTML is where s-maxage earns its keep:

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=30

Browsers revalidate every time (max-age=0), but the CDN edge absorbs traffic for 60 seconds and serves stale for up to 30 more while it refetches. Your origin sees one request per edge location per minute instead of one per user. The cost is that a deploy can take up to a minute to propagate — either accept that or purge HTML paths on deploy. Purge HTML, never assets.

Mistakes I keep seeing

  • Long max-age on HTML. The classic "users are stuck on last week's release and support tells them to hard-refresh" bug. HTML freshness must be zero or near-zero.
  • no-store everywhere out of caution. You've disabled caching entirely, including 304s. Use no-cache unless the response is genuinely sensitive.
  • Version in the query string (app.js?v=123) instead of the filename. Some caches and proxies treat query strings inconsistently. Filename hashing is the reliable form, and your bundler already does it.
  • immutable without hashed filenames. You've told browsers never to check again for a URL whose content you plan to change. The only fix your users have is clearing their cache manually.
  • Forgetting Vary. If a URL's response differs by Accept-Encoding or Origin, say so, or shared caches will serve the wrong variant.

References

  • [MDN: Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
  • [MDN: ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
  • [RFC 9111 — HTTP Caching](https://www.rfc-editor.org/rfc/rfc9111)
  • [web.dev: Prevent unnecessary network requests with the HTTP Cache](https://web.dev/articles/http-cache)

If you build with Vite, Astro, Next's static export, or any modern bundler, the hashed filenames that make rule 1 safe are already in your dist/ folder — the strategy applies as-is to a static site deployed on PandaStack. Push one to https://pandastack.io and watch the asset URLs change between deploys while the HTML stays put.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Guide

Browse all Guide articles →

See also