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.

SettingSecond requestMeaning
max-age=60Doesn't reach the serverFor 60 seconds, use the stored copy without asking
no-cacheOnly checks with the server → 304Store it, but confirm it's current every time you use it
no-storeDownloads everything againDon'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.

  1. The server sends an ETag, a fingerprint of the content, with the first response. (ETag: "661f9b33...")
  2. On the next request, the browser asks with If-None-Match: "661f9b33...": "I have this version; has it changed?"
  3. 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

DirectiveMeaning
max-age=secondsUse the stored copy without asking the server for this long
no-cacheStore it, but check with the server every time before using it
no-storeDon't store it anywhere
privateBrowser only. Shared caches like CDNs must not store it (per-user data)
publicShared caches (CDNs) may store it too
s-maxage=secondsmax-age for shared caches (CDNs) only. Browsers ignore it
immutableIt won't change while fresh, so don't revalidate even on reload
stale-while-revalidate=secondsServe 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?"

TargetRecommendedWhy
JS/CSS with a hash in the filename (app.3f9a1c.js)public, max-age=31536000, immutableNew content gets a new filename, so the same name always means the same content
HTML documentsno-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=86400They change occasionally, so a moderate lifetime
Public API (a list of posts)public, s-maxage=60, stale-while-revalidate=300The CDN answers for a minute and stays fast while refreshing
Per-user APIprivate, no-cache or no-storeMust 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 / problemReality / fix
no-cache means no cachingIt means store and check every time. No storing is no-store
Old page after a deployno-cache for HTML, long caching only for hashed JS/CSS
Downloading the full response every timeETag + 304 to download only when it changed
Personal data shown to someone elseprivate or no-store for per-user responses
Heavy server (function) loads-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?"