What you'll learn
Quick Answer
HTTP caching lets browsers and CDNs reuse a response instead of downloading it again. Cache-Control sets how long a response may be reused, while ETag and Last-Modified let the browser ask whether its copy is still current and receive a small 304 if so. The standard pattern is to cache content-hashed assets effectively forever and never cache HTML, which gives speed without stale pages after a deploy.
Why Caching Matters
The fastest request is the one never made. If a browser already holds a valid copy of your stylesheet, it uses it with no network round trip at all.
That matters more than it sounds. On a mobile connection each request costs a round trip, and the connection setup can exceed the transfer time for small files. Eliminating requests beats making them faster.
Caching happens at several levels, all using the same headers: the browser cache on the user's device, a CDN edge near them, and any proxy in between.
Two kinds of caching are worth distinguishing.
Fresh — the browser considers its copy valid and uses it with no request whatsoever. Instant.
Revalidation — the browser asks "has this changed since the version I hold?" If not, the server replies 304 Not Modified with no body. Still a round trip, but a tiny response.
A 304 in the network tab looks like an error to beginners. It is the second-best possible outcome.
Cache-Control: The Main Header
Cache-Control: max-age=31536000, immutable
Cache-Control: no-cache
Cache-Control: no-store
Cache-Control: public, max-age=3600
Cache-Control: private, max-age=600max-age is the number of seconds the response may be reused without asking. 31536000 is one year, the conventional maximum.
no-cache does not mean do not cache. This is the most misunderstood value. It means the response may be stored, but must be revalidated before use. Every request asks the server, and the server often replies 304.
no-store is the one that means do not cache — nothing is written to disk. Use it for genuinely sensitive responses such as banking pages.
public vs private — public allows shared caches such as CDNs to store it; private restricts storage to the user's own browser. Use private for anything personalised, or a CDN may serve one user's page to another.
immutable tells the browser this content will never change at this URL, so it should not revalidate even on a reload. It is only correct for content-hashed filenames.
ETag and Last-Modified
When a cached response expires, the browser can revalidate rather than downloading again.
ETag is an identifier for a specific version of a resource, usually a hash of the content.
-- First response
ETag: "a1b2c3d4"
-- Later request
If-None-Match: "a1b2c3d4"
-- Server replies, unchanged
HTTP/1.1 304 Not Modified (no body — saves the whole transfer)Last-Modified does the same with a timestamp, paired with If-Modified-Since. It is less precise, since it has one-second resolution and a file can be rewritten with identical content.
Prefer ETag where both are available. Most web servers generate them automatically for static files.
The saving is substantial: a 304 response is a few hundred bytes regardless of whether the file is 500 KB or 5 MB. You still pay the round trip, which is why fresh caching beats revalidation, but it is far better than re-downloading.
The Stale Deploy Problem, and Its Fix
This is the bug everyone hits eventually. You deploy a fix, and users keep seeing the old version — sometimes for days.
The cause: you set a long max-age on app.js, so the browser will not even ask for a new copy until it expires. You cannot force it to. The user's browser genuinely believes it has a valid file.
The solution is to change the URL when the content changes, using a content hash in the filename:
app.a1b2c3d4.js → new build → app.e5f6g7h8.jsBecause the URL is different, the browser has no cached copy and fetches it. The old file is never requested again, so caching it for a year is safe and correct.
Every modern build tool does this automatically. That produces the standard configuration:
# Hashed assets — cache forever, URL changes when content changes
/assets/*.[hash].js Cache-Control: public, max-age=31536000, immutable
# HTML — never cache, it references the hashed filenames
/index.html Cache-Control: no-cache
# Non-hashed static files — revalidate
/images/* Cache-Control: public, max-age=86400The HTML must not be cached long, because it is what points at the new hashed filenames. Cache the HTML and users keep loading the old page referencing the old assets — which is exactly the situation you were trying to avoid.
Practical Rules and Debugging
Sensible defaults by content type:
- Hashed JS and CSS — one year, immutable.
- HTML —
no-cache, so it always revalidates and deploys take effect immediately. - Images and fonts — a long max-age if their names are stable, or hash them too.
- API responses — usually
no-storeor a short max-age withprivate. Never cache a personalised response publicly.
Debugging what is actually happening:
curl -sI https://yoursite.com/assets/app.js | grep -iE 'cache-control|etag|age'In DevTools, the Network tab's Size column shows (disk cache) or (memory cache) for cached responses, and the status shows 304 for revalidation. Tick Disable cache while developing so you are not debugging a stale file.
Note that a hard refresh is not what your users do. Ctrl+Shift+R bypasses the cache for you, which is exactly why a caching bug can look fixed on your machine and remain broken for everyone else. Test in a private window instead.
If you are behind a CDN, remember there are two caches. Purging the CDN does not clear browsers, and clearing your browser does not affect the CDN edge. After changing cache headers, purge the CDN so it stops serving the old ones.
