Using HTTP Caching Correctly: Cache-Control, ETag, and no-cache vs no-store
no-cache doesn't mean 'don't cache.' How max-age, no-cache, no-store, s-maxage, and immutable actually behave, checked by counting how many requests reach the server from a real browser, plus ETag and 304, old files showing up after a deploy, and Vercel's CDN cache.
You deployed. Why do users still see the old page?
The deploy is done, but users still see the old screen. Or the opposite: you added no-cache to "stop caching," yet the server logs show fewer requests than you expected. Both come from misunderstanding HTTP caching.
HTTP caching means storing a response and reusing it, and the rules come from the Cache-Control header the server sends. Caches live in the browser and in CDNs in between.
Send the same request twice
I served the same JSON while changing only Cache-Control, and requested the same URL twice from a browser (Chromium). The server log records only requests that actually reached the server.
### Cache-Control: max-age=60
[server] /max-age -> 200 ← only the first one reaches the server
← the second never arrives (served from the browser cache)
### Cache-Control: no-cache
[server] /no-cache -> 200
[server] /no-cache if-none-match="661f9b33..." -> 304 ← the second just asks "has it changed?"
### Cache-Control: no-store
[server] /no-store -> 200
[server] /no-store -> 200 ← fetched from scratch every time
In all three cases, JavaScript got a 200 with the same body both times. The difference is whether the request reached the server and whether the body was downloaded again.
| Setting | Second request | Meaning |
|---|---|---|
| max-age=60 | Doesn't reach the server | For 60 seconds, use the stored copy without asking |
| no-cache | Only checks with the server → 304 | Store it, but confirm it's current every time you use it |
| no-store | Downloads everything again | Don't store it at all |
This is the most confusing part because of the name. no-cache doesn't mean "don't cache"; it means "don't use it without checking." If you really need to prevent storage (personal data, payment info), use no-store.
ETag and 304: download again only when it changed
What happened on the second no-cache request is a conditional request.
- The server sends an ETag, a fingerprint of the content, with the first response. (ETag: "661f9b33...")
- On the next request, the browser asks with If-None-Match: "661f9b33...": "I have this version; has it changed?"
- If it hasn't, the server sends just 304 Not Modified with no body, and the browser uses its stored copy.
// Handling ETag in Node.js
import crypto from "node:crypto";
const body = JSON.stringify(posts);
const etag = `"${crypto.createHash("sha1").update(body).digest("hex").slice(0, 16)}"`;
res.setHeader("Cache-Control", "no-cache");
res.setHeader("ETag", etag);
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304; // no body: "you can keep using yours"
res.end();
return;
}
res.end(body);
The bigger the response and the slower the network (mobile), the more 304 saves. Most web frameworks and static file servers add ETag automatically.
Cache-Control directives at a glance
| Directive | Meaning |
|---|---|
| max-age=seconds | Use the stored copy without asking the server for this long |
| no-cache | Store it, but check with the server every time before using it |
| no-store | Don't store it anywhere |
| private | Browser only. Shared caches like CDNs must not store it (per-user data) |
| public | Shared caches (CDNs) may store it too |
| s-maxage=seconds | max-age for shared caches (CDNs) only. Browsers ignore it |
| immutable | It won't change while fresh, so don't revalidate even on reload |
| stale-while-revalidate=seconds | Serve the expired copy right away and fetch a fresh one in the background |
Put public or s-maxage on a logged-in user's data (a "my account" API, for example) and the CDN can serve one user's response to another user. For responses that differ per user, use private (or no-store).
Practical combinations: different settings per file type
The key to cache settings is matching "when does this response change?"
| Target | Recommended | Why |
|---|---|---|
| JS/CSS with a hash in the filename (app.3f9a1c.js) | public, max-age=31536000, immutable | New content gets a new filename, so the same name always means the same content |
| HTML documents | no-cache (+ ETag) | It must point to the new deploy's new JS filenames, so always check |
| Images (no hash in the name) | Around public, max-age=86400 | They change occasionally, so a moderate lifetime |
| Public API (a list of posts) | public, s-maxage=60, stale-while-revalidate=300 | The CDN answers for a minute and stays fast while refreshing |
| Per-user API | private, no-cache or no-store | Must not be stored in shared caches |
Why users see the old page after a deploy
Usually because HTML was given a long max-age. When HTML is cached, the browser uses the old HTML, and that HTML loads the old JS files. The fix is a simple formula:
- Always revalidate HTML (no-cache)
- Hash JS/CSS filenames and cache them forever (immutable)
Build tools like Vite, Nuxt, and Next.js hash JS/CSS filenames automatically, so you only need to be careful with HTML caching.
CDN caching and Vercel
On platforms like Vercel, there's a CDN between the browser and your server. If your server (function) sends s-maxage, the CDN stores the response for that long and answers in your server's place. This directly affects how often your serverless functions run and how fast responses are.
// API response: browsers revalidate every time, the CDN answers for 60 seconds
res.setHeader("Cache-Control", "public, max-age=0, s-maxage=60, stale-while-revalidate=300");
In Nuxt, you can declare caching per route with routeRules.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
"/": { swr: 600 }, // cache for 10 minutes; after expiry, serve the stored copy and refresh in the background
"/api/posts": { headers: { "cache-control": "public, s-maxage=60" } },
"/mypage/**": { headers: { "cache-control": "private, no-store" } },
},
});
After changing cache settings, check the response headers yourself in the dev tools Network tab. Cache-Control, ETag, Age (how many seconds the CDN has held it), and markers like (disk cache) and 304 show what's really happening.
Summary: why this is worth knowing
| Misconception / problem | Reality / fix |
|---|---|
| no-cache means no caching | It means store and check every time. No storing is no-store |
| Old page after a deploy | no-cache for HTML, long caching only for hashed JS/CSS |
| Downloading the full response every time | ETag + 304 to download only when it changed |
| Personal data shown to someone else | private or no-store for per-user responses |
| Heavy server (function) load | s-maxage + stale-while-revalidate on public responses |
Used well, caching cuts server cost and response time at once. Used badly, it causes "I fixed it but it's not fixed" bugs and privacy incidents. Before adding a header, ask two questions: "When does this response change, and is it the same for everyone?"