HTTP Caching Explained: max-age, ETag and Why Your Users Still See Last Week's CSS

You fixed the CSS, deployed, and a customer sends a screenshot of last week's layout. Nothing is broken — the browser is obeying the header you sent it. Here is HTTP caching from first principles: why max-age is a promise you cannot take back, why no-cache does not mean don't cache, how ETag revalidation buys you a round trip with zero bytes, and why content-hashed filenames are the only invalidation strategy that actually works.

Banner

Prefer to watch? ⚡ The 160-second version ✈ Telegram

You fixed the CSS. You deployed. You opened the site and checked it yourself — perfect.

Then a customer sends a screenshot of last week's layout.

Nothing is broken. No deploy failed, no CDN is lying to you, no file is corrupt. The browser is doing exactly what you told it to do, several days ago, in a header you probably never wrote by hand.

This is the part of web performance that gets skipped, because caching looks like a setting rather than a contract. It is a contract. And like any contract, the interesting part is not what it gives you — it is what you can no longer do once you have signed it.

Throughout this post I will use one running example: PlantPal, a small plant shop. One stylesheet (app.css), one logo (logo.png), one API endpoint (/api/products).


The floor: a page load is not one thing

Before caching means anything, you have to see what it is acting on.

Loading PlantPal's homepage is not a request. It is roughly 40 separate requests — the HTML, the stylesheet, a few fonts, the logo, a dozen product images, the JavaScript bundle, the product API. Each one is a full round trip: DNS is probably warm, but you still pay connection setup, the request, the server's think time, and the bytes coming back.

The numbers for a first visit:

  • ~40 requests
  • 1.2 MB transferred
  • 2.1 s to a usable page

Which gives us the only sentence in this post that you actually need to remember:

The fastest request is the one the browser never sends.

Not a faster server. Not a closer edge node. Not a smaller file. No request at all. Everything below is a way of getting closer to that.


max-age: buying silence

The blunt instrument is Cache-Control:

HTTP/1.1 200 OK
Content-Type: text/css
Cache-Control: max-age=31536000

31536000 is one year in seconds. You are telling every browser that receives this response: keep this copy and use it for a year without asking me again.

On the second visit, the browser does not ask. It reads app.css off the user's own disk. Open the network tab and you will see it:

app.css    200    (disk cache)    0 ms

That is worth pausing on, because it is routinely misread. That is not a fast request. That is no request. Nothing left the machine. There is no server log line for it, no bandwidth bill, no chance for a flaky network to matter. It is the difference between "we optimised the endpoint" and "the endpoint was never called".

The header does not stop at the browser

Cache-Control is not a browser setting. It is a directive to every cache between your server and the user — your CDN, the corporate proxy at your customer's office, the ISP cache, and then the browser.

That is a lot of leverage, and it is also the classic security incident. If a response is user-specific — an account page, a personalised price, anything behind a session — and you send a shared max-age, a shared cache is now entitled to hand your logged-in page to someone else. Which is what private is for:

Cache-Control: private, max-age=600

private means: browsers may store this, shared caches may not. For anything user-specific, that word is not optional.


The catch: you cannot take it back

Now ship the fix.

You correct the stylesheet, deploy, and your server happily serves the new bytes to anyone who asks. The problem is that nobody asks. You told 1,284 browsers to not bother for a year, and they are honouring it. They will keep honouring it on a laptop in another country that you will never touch again.

There is no purge button for a device you do not own.

This is the asymmetry that makes caching feel cursed the first time it bites you. You can purge your CDN in one API call. You can restart your own servers. But the copy sitting in a user's browser profile is beyond your reach — and it got there because you asked for it.

max-age is a promise you cannot take back.

The stale-user counter does not go down on its own. It drains as each user's year expires, or as each user happens to hard-refresh, and both of those are outside your control.


The overcorrection is just as wrong

Having been burned once, the reflex is to turn caching off everywhere:

Cache-Control: no-cache

And now you have paid for your safety by going straight back to the floor: 40 round trips, every visit, forever. Your returning users — the ones who like you enough to come back — get the worst experience on the site. That is precisely backwards.

While we are here, the single most misleading name in HTTP:

DirectiveWhat it actually means
no-cacheStore it, but check with me before every use. The copy is kept on disk; it just may not be served without revalidation.
no-storeDo not write it down at all. This is the one that means "don't cache". Use it for genuinely sensitive responses.
max-age=NUse the stored copy freely for N seconds, no questions asked.
privateBrowser may store; shared caches (CDN, proxy) may not.
immutableThis URL's bytes will never change — do not even revalidate on refresh.

So no-cache is not the off switch. It is the ask every time switch — which turns out to be genuinely useful, once you know what "asking" costs.


Revalidation: a round trip with zero bytes

Between "never ask" and "download it again" there is a third option, and it is the one most people never reach.

The server fingerprints the file and sends the fingerprint along with it:

HTTP/1.1 200 OK
Cache-Control: no-cache
ETag: "a3f9c1"

Next visit, the browser still has the copy, so it does not ask for the file. It asks a question:

GET /app.css HTTP/1.1
If-None-Match: "a3f9c1"

Meaning: I have the a3f9c1 version — is that still current? If it is, the server answers:

HTTP/1.1 304 Not Modified

And that is the entire response. A round trip, with an empty body. You still pay the latency of asking, but you pay none of the bytes. For a 200 KB stylesheet that is a very good trade; for 40 small assets it is still 40 round trips, which is why revalidation is a middle rung and not the destination.

There is an older, weaker version of the same idea — Last-Modified paired with If-Modified-Since — which compares timestamps instead of content. It works, but it has one-second resolution and it gets confused by builds that rewrite a file without changing it. ETag compares the content itself, so prefer it.


The actual answer: change the URL

Here is the move that dissolves the problem instead of managing it.

Stop trying to invalidate a cached file. Make a changed file a changed URL.

Put a hash of the file's contents into its own name:

app.a3f9c1.css      →  after the fix  →  app.7b2e04.css

And then cache it as hard as you possibly can, because now you are allowed to:

Cache-Control: max-age=31536000, immutable

Nothing about this needs invalidation, because nothing is ever overwritten. app.a3f9c1.css is that stylesheet, permanently and truthfully. When you deploy the fix, you are not updating a file — you are publishing a new file at a new URL, and old browsers holding the old one are holding something that is still perfectly valid for the version they were told about.

Every bundler does this for you ([contenthash] in webpack, the default in Vite, Next, Rails, and friends). It usually arrives as a build-config detail. It is not: it is the whole invalidation strategy.

The one file you cannot hash

The chain has to start somewhere. Something has to name app.7b2e04.css, and that something is index.html — which is fetched by URL and therefore cannot itself carry a content hash.

So index.html is the exception, and it is deliberately the opposite of everything else:

Cache-Control: no-cache

It is small. It revalidates on every visit — one round trip, usually a 304 with an empty body. And that one cheap question is what lets every other asset on the page be cached forever, because the HTML is where the new hashed names appear the moment you deploy.

That is the shape of a correct setup:

AssetHeaderWhy
index.htmlno-cacheCannot be hashed; it is the pointer to everything else. Revalidate every visit.
app.a3f9c1.css, bundle.7b2e04.js, fontsmax-age=31536000, immutableName contains the fingerprint. A change is a new URL, so it never needs invalidating.
logo.png (unhashed, rarely changes)max-age=86400 or ETag + no-cacheBounded staleness you can live with, or a cheap 304.
/api/productsno-cache + ETag, or short max-ageDynamic. Revalidate cheaply; add private if it is user-specific.

The payoff on PlantPal's second visit:

  • 40 requests → 3
  • 1.2 MB → 4 KB
  • 2.1 s → 300 ms

And the part that matters more than any of those numbers: a fix now reaches every user on their next page load, without anyone being told to try a hard refresh.


The same contract, one layer up: caching in LLM serving

If you work on AI systems rather than web frontends, do not skip this section — you are running the exact same contract, and the vocabulary changed but the failure modes did not.

Prompt / prefix caching is max-age with a different name. When you send a long system prompt plus a fat block of retrieved documents to a model, the provider can cache the attention state (the KV cache) for the prefix of that request and reuse it on the next call. You pay full price for the first call and a fraction of it afterwards, and latency to first token drops accordingly. It is the same principle as the disk cache: the cheapest tokens are the ones the model never has to re-process.

And it comes with the same catch, in an even sharper form. A prefix cache is keyed on an exact prefix. Change one character near the top of your system prompt — a stray space, a reordered tool definition, a timestamp you helpfully injected — and every downstream token is a different prefix. The cache does not "mostly" hit. It misses entirely, your costs jump, your p95 latency jumps, and nothing in your code changed in a way you would notice in review.

Which means the fix is the one you just read: keep the volatile part out of the cached prefix. Static system prompt and tool definitions first, then retrieved context, then the user's turn — variable content last, exactly the way index.html sits in front of hashed assets. A prompt whose first line contains today's date is a stylesheet with a random query string on it.

The rest of the mapping is just as direct:

HTTP cachingThe LLM-serving equivalent
max-age — reuse without askingPrefix / prompt caching: reuse the computed KV state for an identical prefix
ETag + 304 — cheap "is this still current?"Semantic cache: an embedding lookup that answers "have we already answered this question?" for a fraction of a generation
Content-hashed filename — a change is a new URLVersioned prompt and index identifiers: prompt@v7, faiss-index-2026-08-20. A changed prompt is a new key, not an edited one.
no-store for sensitive responsesNever cache user-specific generations across tenants — the shared-cache leak, with someone's private data as the payload
Stale users you cannot reachStale retrieval: your document changed, the embedding did not, and the model is confidently citing last week's policy

That last row is the one that bites hardest in RAG systems, and for exactly the reason from the first half of this post: you cached a derived artifact and then changed the source. The embedding of a document is a cached representation of it. Update the document without re-embedding and you have built the same trap as a one-year max-age on a file you later fixed — the system is not broken, it is faithfully serving what you told it was true. The remedy is also the same: version the key. Tie the embedding to a content hash of the document so a changed document produces a different key, rather than silently occupying the old one.


The verdict

Caching is not a performance toggle you turn up until something breaks. It is a decision about who is allowed to stop asking, and for how long — and it is the only one of those decisions you cannot revoke after the fact.

  • Never cache and you pay full price on every visit, forever. Your loyal users suffer most.
  • Cache blindly with a long max-age on a stable filename and you eventually ship a fix that a chunk of your users will not see for a year, with no way to reach them.
  • Revalidate with ETag and you get correctness for the price of a round trip and zero bytes — the right call for anything you cannot fingerprint.
  • Content-hash the filename and cache forever and the question disappears, because you never invalidate anything. A changed file is a changed URL.

Or, the version worth carrying into your next deploy:

You don't clear a browser cache — you can't reach it. You either agreed on an expiry date up front, or you change the URL.

Your turn: you shipped app.css with a one-year max-age and no hash in the filename. It has a bug. What can you actually do for the users who already have it?

(The honest answer is uncomfortable and worth sitting with: for that exact URL, nothing. You change the URL the HTML points at, and you accept that the users who never re-fetch the HTML are the ones you have to wait out. Which is the argument for getting the headers right before the first deploy, not after the first incident.)


Prefer the 160-second version? Watch the Short — or follow along on Telegram for more CS fundamentals and system design.

HTTP Caching Explained: max-age, ETag and Why Your Users Still See Last Week's CSS | Software Engineer Blog