HTTP Caching Explained: Cache-Control, ETag and 304 Responses
By Nitheesh Rajendran · · 4 min read
HTTP caching lets a browser or CDN reuse a response instead of fetching it again. Done well, it makes pages faster and cuts server load. Done badly, users see stale content that cannot be fixed without a hard refresh. Most of the behaviour is controlled by a few response headers, and the rules are simpler than they first appear.
Two ideas: freshness and validation
Every caching decision comes down to two questions:
- Freshness: can the stored copy be used without asking the server? That depends on how long the response is declared fresh.
- Validation: if the copy is stale, can the client ask the server "has this changed?" and get a cheap "no" instead of the whole body again?
Cache-Control answers the first question. ETag and Last-Modified support the second.
Cache-Control directives you will actually use
| Directive | Meaning |
|---|---|
max-age=N |
The response is fresh for N seconds. |
no-cache |
May be stored, but must be revalidated with the server before reuse. |
no-store |
Do not store the response at all (sensitive data). |
public |
Any cache, including shared CDNs, may store it. |
private |
Only the user's own browser may store it, not shared caches. |
immutable |
The content will never change while fresh, so skip revalidation. |
s-maxage=N |
Like max-age, but for shared caches (CDNs) only. |
A frequent misunderstanding: no-cache does not mean "do not cache". It means "cache it, but check before using it". If you want nothing stored, use no-store.
Validation with ETag and 304
An ETag is an identifier for a specific version of a response, often a hash of its content. The client stores it and sends it back on the next request in an If-None-Match header. If the version has not changed, the server replies 304 Not Modified with no body.
The following small Node.js server shows the full exchange:
const http = require("http");
const crypto = require("crypto");
const body = JSON.stringify({ hello: "world" });
const etag = '"' + crypto.createHash("sha1").update(body).digest("hex").slice(0, 12) + '"';
http.createServer((req, res) => {
if (req.url === "/api") {
res.setHeader("ETag", etag);
res.setHeader("Cache-Control", "no-cache");
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
return res.end();
}
res.setHeader("Content-Type", "application/json");
return res.end(body);
}
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.end("asset");
}).listen(3199);
Requesting /api the first time returns the body and an ETag:
HTTP/1.1 200 OK
ETag: "2248ee2fa0aa"
Cache-Control: no-cache
Sending that tag back with curl -H 'If-None-Match: "2248ee2fa0aa"' localhost:3199/api produces:
HTTP/1.1 304 Not Modified
ETag: "2248ee2fa0aa"
The browser still makes a request, but the response has no body, so it is small and fast. Any other path returns the long-lived header:
HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable
A practical strategy
Fingerprinted static assets: cache for a year
Files like app.4f2a1c.js have a hash in the name that changes whenever the content changes. Since the URL is unique to the content, they can be cached for the maximum useful time:
Cache-Control: public, max-age=31536000, immutable
Build tools (including Next.js, Vite and webpack) produce fingerprinted file names for you.
HTML pages: revalidate
The HTML document references those assets, so it must stay up to date. Use no-cache (with an ETag) or a short max-age. Users get the new HTML promptly, and it points to the new asset URLs.
API responses
- Public, slowly changing data: a short
max-ageors-maxage, optionally withstale-while-revalidate. - Per-user data:
private(orno-storefor sensitive data), so a CDN never serves one user's response to another.
Use Vary when responses differ by header
If a response differs by a request header such as Accept-Encoding or Accept-Language, include a Vary header so caches keep separate copies.
Checking what you serve
In the browser, open DevTools → Network and look at the Size column: "(memory cache)" and "(disk cache)" mean no request was made, and a 304 means revalidation succeeded. From a terminal:
curl -sI https://example.com/app.js | grep -iE "cache-control|etag|age"
The Age header, set by shared caches, tells you how many seconds a response has been sitting there.
Common mistakes
- Using
no-cachewhen you meantno-store, or the reverse. - Caching HTML for a long time, so users keep seeing old asset references.
- Long
max-ageon files whose names do not change (style.css), which makes updates invisible. - Marking personalised responses
public. - Forgetting that a CDN may cache differently from the browser; set
s-maxageexplicitly when you need to.
Summary
Cache fingerprinted assets for a year, make HTML and APIs revalidate cheaply with ETag, and mark private data private or no-store. Once you separate "can I reuse this without asking?" from "can I check cheaply?", the headers stop being mysterious.