HTTP caching: why the second visit to a site is instant

Take a single style.css file and follow its day on a live site: first the browser downloads it, then it serves it from memory without asking the server, then it asks the server whether its copy is still current and usually gets just a confirmation instead of the whole file, and after you deploy a new version it downloads it again. That one story is all of HTTP caching - Cache-Control, ETag, 304, and stale-while-revalidate.
You open a site, come back to it a moment later - and it loads instantly. You deploy a CSS fix, refresh, and you still see the old look. Behind both is the cache: a previously stored server response that the browser reused instead of fetching it again.
The easiest way to see how it works is on a single file. Let’s take style.css and follow what happens to it, from the first visit to the site all the way to deploying a new version.
#First visit: the cache is empty
Someone opens the site for the first time. The browser needs style.css but doesn’t have it, so it fetches it from the server (from the origin - where the file actually lives). In the response, the server adds a few headers that say how to treat this file in the future:
HTTP/1.1 200 OK
Cache-Control: max-age=3600
ETag: "v1-abc"Cache-Control: max-age=3600 means: this response can be treated as fresh for 3600 seconds, i.e. one hour. ETag: "v1-abc" is an identifier for this specific version of the file - useful later, to check whether the file has changed.
The browser stores the file together with these headers and renders the page. The first visit always costs a full download - the cache had nothing to use yet.
#Next visit: the response straight from cache
The user navigates to another page that also uses style.css. This time the browser sends no request at all. It sees it got the file a few minutes ago and freshness is an hour, so it simply uses its copy.
That’s the instant second visit: as long as the file is fresh, there’s no network traffic at all.
The same copy can also live in front of the browser. Between it and the origin there’s often a CDN - a shared cache that keeps one copy for many users. The server can give it a different lifetime than the browser:
Cache-Control: max-age=60, s-maxage=3600s-maxage applies only to shared caches. Here the browser keeps the file for a minute, the CDN for an hour. That way most users get the file from the nearest CDN node, without hitting the origin.
#After an hour: the file is stale
After an hour the browser’s copy becomes stale. That doesn’t mean “throw it away” - it only means “don’t use it before you check whether it’s still current”.
And this is the most important saving in all of caching. The browser doesn’t re-download the file - first it asks the server whether its copy is still current. It sends a conditional request with the identifier it got earlier:
GET /style.css HTTP/1.1
If-None-Match: "v1-abc"The server compares "v1-abc" with the current version and answers in one of two ways:
nothing changed → 304 Not Modified (no body)
new version → 200 OK (full, new file)If the file hasn’t changed, the server returns 304 Not Modified - a response with no body, just a confirmation that “your copy is good”. The browser gets a few bytes instead of the whole file and keeps using what it has. A full download happens only when a new version actually appeared.
That’s why staleness isn’t a problem - at worst it costs one lightweight check. Instead of ETag, the server can compare dates with the Last-Modified / If-Modified-Since pair; it works the same way, and ETag is just more precise, because it reacts to any change in content, not only a change of date.
Follow it on the simulator below. This is style.css with max-age=60:
Click in order: visit the page, advance time, deploy a new version - and watch what goes over the wire each time.
Hit “Visit page” to start.
#You deploy a new version - and see the old one
You swap style.css for a new one with the fixed look, go to check - and you still see the old version. It’s not a browser bug. Your copy of style.css still has a fresh max-age, so the browser has no reason to ask the server anything - it does exactly what it was told earlier: it uses the fresh copy.
The temptation is to set max-age to zero and be done with it. But then you lose the entire benefit of caching - every visit hits the server again. You want two things that pull in opposite directions: keep files cached as long as possible, and at the same time see a deploy instantly.
The trick that practically every bundler uses (Vite, webpack, and others) solves it: the filename contains a hash of its content.
style.9f2a3c.cssChange even one character in the CSS - the hash is recomputed and the file gets a different name (style.7b1e0d.css). To the cache, a different name is a different file: nobody has it yet, so the browser downloads it normally. The old name stays in the cache, but nobody will ask for it again.
Since the name changes on every change of content, the file under a given name is immutable - so you can cache it for as long as possible:
hashed files (CSS, JS, images) → Cache-Control: max-age=31536000, immutable
HTML that points to them → Cache-Control: no-cachemax-age=31536000 is a year, and immutable adds: “don’t even check after it expires, this name always means the same file”. Zero requests to the server that whole time - and it’s safe, because if the content changed, the file would already have a different name.
One question remains: how does the browser know to take style.7b1e0d.css now, and not the old style.9f2a3c.css? From the HTML - it’s what lists the filenames to load. That’s why the HTML is the one file that must be fresh right after a deploy, and gets no-cache: “store it, but always ask the server first”. That check is cheap and usually ends in a 304.
And that ties it all together. On every visit the browser checks only the HTML. It hasn’t changed - 304, and all the hashed files come from the cache with no network traffic. It has changed - it arrives fresh and points to new names that nobody has yet, so the browser pulls exactly the files that actually changed. Maximum caching and instant deploys at once.
#Not everything can be cached like an ordinary file
So far style.css was convenient: the same file for everyone, fine to keep for a long time. But not every server response looks like that - a page with a logged-in user’s data, or an account balance, can’t sit in the cache like a public CSS file.
This is decided by the same Cache-Control header that earlier held max-age. Beyond a lifetime, it can carry directives saying who may store the response and whether it can be stored at all. Three of them sound similar but mean different things (a classic interview question):
private- the response is for one specific user (e.g. a page with their data after logging in). It may be kept only in the browser cache, never in a shared CDN - otherwise one user could get another’s data.no-cache- store it, but never use it without checking on the server. It sounds like “don’t cache”, but it means “always ask first”. That’s exactly the HTML mode from the previous section.no-store- don’t store it anywhere. For sensitive data (account balance, personal data) that has no business landing in any cache.
#Serve the stale, check in the background - stale-while-revalidate
Let’s go back to the moment the file was stale and the browser had to wait for the 304. It’s a brief moment, but with a slow server the user waits anyway. There’s a way around it - described by a separate spec, RFC 5861:
Cache-Control: max-age=600, stale-while-revalidate=86400For 600 seconds the file is fresh. After it expires, for the next 24 hours the browser serves the stale copy right away (the user doesn’t wait) and does the freshness check in the background. The next user already gets the refreshed version. The cost: someone occasionally sees content from a moment ago. For most sites that’s a good trade-off.
It’s the same idea as ISR from the post on rendering strategies: serve the ready version now, refresh in the background. ISR is stale-while-revalidate applied to whole HTML pages, not to a single file.
The related stale-if-error directive says: if the server goes down (500, 502, 503, 504), serve the stale copy instead of an error. The site keeps working on the last good version even though the backend is down.
#What to remember
This whole example came down to one question the browser asks itself every time it needs a file: do I even have to talk to the server, and if so - can I at least avoid downloading the whole thing again. Hence three takeaways:
max-ageis freshness. As long as a response is fresh, it comes from the cache with no network traffic. Once it expires it becomes stale - that is, “check before use”, not “discard”.- Revalidation saves transfer. The browser asks with an
ETag, and the server replies304 Not Modifiedwith no body if nothing changed. A full download only on a real change. - A hash in the filename solves deploys. Hashed files get
immutablefor a year, the HTML getsno-cache. Maximum caching and instant deploys at once.
Next time you deploy a change and see the old version, don’t clear the cache by hand - check in the Network tab what Cache-Control your HTML has and whether the files have a hash in their name. It’s not the browser getting it wrong - it’s the headers you set yourself telling it the old copy is still good.
Comments
Loading comments…