HTTP Cache-Control Headers Explained on a Real Next.js Site
Learn HTTP Cache-Control headers from a real next.config.ts: max-age, immutable, no-cache vs no-store, ETag and 304, and how Cloudflare reads them.
By Matthews WongReviewed on
Key takeaway
Cache-Control is a response header that tells browsers and CDNs how long they may reuse a response without asking the server again. max-age sets the fresh time, no-cache forces validation before reuse, no-store forbids storing, and immutable promises the file never changes. It is only safe for a long time when the URL changes with the content.
You will be able to
- Read a Cache-Control header and say what max-age, s-maxage, no-cache, no-store and immutable each promise.
- Explain why a one-year lifetime is safe for hash-named files and wrong for a name that never changes.
- Predict what Cloudflare will cache from a response's headers, and read cf-cache-status to check.
Step through the diagram
Press Play, or step with the arrows. Every step is written out below the diagram.
How one header decides who answers
A browser asks a CDN edge, which asks the origin. A hashed file with a year-long immutable header is answered from cache from then on; a file that can change under a fixed name must be validated.
Keys: ← → step · Home / End · K play or pause
Three places can hold a copy: the browser's own cache, the CDN edge, and the app itself. Cache-Control on the response tells the first two how long to keep one.
The first request travels all the way to the app. A hash-named file comes back with a year-long, immutable header, and the edge reports a MISS.
Cache-Control: public, max-age=31536000, immutableThe edge stores the file because the header is public with a max-age above zero. The browser keeps it too, so a fresh repeat visit sends no request.
Predict the next step A different visitor now asks for the same hashed file. Who answers? (The app, every time · The CDN edge, from its cache · Nobody: the request fails)The edge answers from its copy and reports cf-cache-status HIT. The request never reaches the app, so the origin does no work for this file again.
A new deploy produces a new hash, so a new URL. The old URL stays cached and harmless, and no one needs to purge anything to see the change.
Predict the next step A file keeps the same name but its content changed, and its cached copy is stale. What does a cache do next? (Serves the old copy forever · Asks the origin with a conditional request · Deletes it without asking)A name that can never change cannot carry a year-long promise. A stale copy is revalidated: the cache sends its ETag, and the app answers 304 Not Modified when nothing changed.
If-None-Match: the saved ETag 304 Not Modified
What does Cache-Control tell a cache to do?
Cache-Control is a response header that tells every cache between the server and the screen how long it may reuse a response. max-age=N means the response stays fresh until N seconds after it was generated. s-maxage says the same for shared caches only, such as a CDN, and overrides max-age there. A browser's own cache is private to one client; a CDN or reverse proxy is shared between many.
Read more (2)Show less
Two directives are constantly confused. no-cache does not mean do not cache: the response may be stored, but it must be validated with the origin before each reuse. no-store is the one that means do not store, in any cache. public lets shared caches store the response, and immutable says it will not be updated while it is fresh.
With no Cache-Control at all, caches may still store a response and reuse it for a period they pick themselves. That is called heuristic caching, and MDN's advice is that basically every response should state its Cache-Control explicitly, so the behaviour is a decision rather than a guess.
Why is a one-year lifetime safe for some URLs and wrong for others?
A long lifetime is only safe when the content behind a URL never changes. The cache-busting pattern makes that true: put a version or a content hash in the URL, never modify the file, and send Cache-Control: public, max-age=31536000, immutable. A new version gets a new URL, so no cache can hold the wrong body for a name. 31536000 seconds is one year.
Read more (2)Show less
Next.js applies the pattern itself: it sets exactly that header on truly immutable, hash-named assets, and its docs say you cannot override it from next.config. This site sends the same header for /images/*, for root-level image files and, in production only, for /_next/static/*. The production-only condition is deliberate: development chunks keep their names across edits, so a year of caching there ran stale code in browsers.
The rest of next.config.ts shows how to treat names that cannot change. Films are immutable only when the URL carries a ?v= render stamp. The game's files are content-hashed, but its manifest.json, whose name never changes, is left out of the rule. HTML cannot be cache-busted at all, which is why MDN recommends no-cache for it: store it if you like, but ask the origin first.
How does Cloudflare act on these headers?
Cloudflare decides what is eligible by file extension, not by MIME type, and it does not cache HTML or JSON by default. For eligible files it follows the origin: a response that is public with a max-age above zero is cached, while private, no-store, no-cache or max-age=0 keeps it out. A response that sets a cookie is not cached, and only GET requests are.
Read more (2)Show less
You can read its decision in the cf-cache-status response header. HIT means the file came from Cloudflare's cache. MISS means it was cacheable but not stored yet, so the origin answered. DYNAMIC means it was judged not eligible, so no cache lookup happened. EXPIRED, REVALIDATED, BYPASS and STALE cover the remaining cases. Check any URL with curl -sI.
Two more facts help when you edit rules. In Next.js, when two header rules match one path and set the same key, the last one wins. And if a cached file must go before it expires, Cloudflare offers several purge methods, with single-URL purge as its recommended one. A new URL needs no purge at all, which is the point of the previous section.
What happens when a cached copy goes stale?
Stale does not mean deleted. A cache can revalidate with a conditional request: it sends the validator it saved, usually an ETag, in an If-None-Match header. If nothing changed, the server answers 304 Not Modified with no body and the cache marks its copy fresh again. If it changed, the server returns 200 with the new body.
Read more (1)Show less
That is why no-cache suits a fixed-name file such as HTML: the common case costs one small round trip instead of a full download. immutable removes even that request when a visitor reloads, which MDN says is unnecessary for files that are never modified, but it is only honest when the name is never reused.
The real config behind this lesson
Excerpts from the repository that builds and deploys this website, quoted as they are.
// Production only. Build chunks are content-hashed there, so a year of
// immutable caching is safe. Turbopack's dev chunks keep the same name
// across edits, and this header in development made browsers keep
// running stale game code for as long as their cache lived — the
// "sometimes WASD stops working" reports were a mix of old and new
// modules, not the controller.
...(process.env.NODE_ENV === "production"
? [
{
source: "/_next/static/(.*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
}, // Cache static assets for 1 year
],
},
]
: []),The one-year rule for build chunks, behind a production check. The comment is the lesson: the header is safe only because production chunks are content-hashed, and the same header in development once made browsers run stale game code. The matching rule for images, at lines 157-177, has no such condition because those URLs are served as they are.
{
// The films, their posters and captions. A re-render keeps the file
// names, so only a request carrying the render stamp
// (lib/film-manifest.ts prints `?v=` on every URL) may be cached as
// immutable; Cloudflare then serves the films from its edge. A bare
// URL, from an old link, keeps revalidating against the origin.
source: "/videos/:path*",
has: [{ type: "query", key: "v" }],
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},A name that does not change, handled with a condition. has matches only requests that carry a v query parameter, so a re-rendered film is immutable under its stamped URL while a bare URL from an old link keeps revalidating. The comment says Cloudflare then serves these films from its edge.
{
// The Archipelago's models, textures and decoders (prd.md §7). Files
// are content-hashed by scripts/game/build-manifest.mjs, so they can
// be immutable; Cloudflare then serves every repeat from the edge and
// the 1 vCPU origin never streams a model twice. The manifest itself
// is excluded: it is the one file whose name never changes.
source: "/game/:path((?!manifest\\.json$).*)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},The same idea by exclusion. A negative lookahead in the path pattern matches everything under /game/ except manifest.json. Every other file is content-hashed, so it can be immutable; the manifest is the one file whose name never changes, so it gets no year-long promise from this rule.
Mistakes that cost real time
immutable on a name that gets reused
A year-long immutable header on a name whose content changes leaves old copies in every browser that fetched it. Purging a CDN does not reach copies already stored in browsers, because a browser cache is private to each client. Change the URL instead, or leave the long promise off.
Long caching in development
Turbopack's development chunks keep the same name across edits. This site once sent the immutable header in development too, and browsers ran a mix of old and new game modules. The fix is the production check in next.config.ts. Verify the header you ship, not the one you meant to ship.
no-cache read as do not cache
no-cache allows storing and only demands validation before reuse. A page with private data that must not be kept anywhere needs no-store, and a personalised response also needs private so shared caches skip it. Reading the name literally is how private pages end up stored.
A cacheable file that never caches
A perfect Cache-Control is ignored when Cloudflare sees a Set-Cookie on the response, a non-GET request, or a file extension outside its default list. If cf-cache-status says DYNAMIC for an image or script, look at those before the header.
| Directive | May be stored | Reused without asking |
|---|---|---|
| max-age=N | Yes | For N seconds |
| s-maxage=N | In shared caches | For N seconds, overriding max-age there |
| no-cache | Yes | Never: validate first |
| no-store | No | Not applicable |
| immutable | Yes, with max-age | While fresh, even on reload |
Do it on your own machine
You need curl and Node. First read the live headers of this site, then build a tiny server that answers 304.
Read the headers an HTML page carries. Note whether Cache-Control and cf-cache-status are present, and what they say.
curl -sI https://www.matthewswong.com/en/ | grep -i -E "cache-control|cf-cache-status"Find one hashed chunk the page loads, then request it twice. Compare the two cf-cache-status values and the Cache-Control line with the rule in next.config.ts.
CHUNK=$(curl -s https://www.matthewswong.com/en/ | grep -o "/_next/static/[^\"]*\.js" | head -1) curl -sI "https://www.matthewswong.com$CHUNK" | grep -i -E "cache-control|cf-cache-status" curl -sI "https://www.matthewswong.com$CHUNK" | grep -i -E "cache-control|cf-cache-status"Write a server that sends an ETag and answers 304 when the same ETag comes back.
// server.mjs import { createServer } from "node:http"; const ETAG = '"v1"'; createServer((req, res) => { if (req.headers["if-none-match"] === ETAG) { res.writeHead(304, { ETag: ETAG }); return res.end(); } res.writeHead(200, { ETag: ETAG, "Cache-Control": "no-cache" }); res.end("hello\n"); }).listen(3000);Run it, then request it plainly and again with the validator. The second call should print 304 and no body.
node server.mjs & curl -i http://localhost:3000 curl -i -H 'If-None-Match: "v1"' http://localhost:3000 kill %1
Three questions before you move on
Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.
Answer in your head, then check
1.A hash-named file changes in a new deploy. What must you do to its cache entries?Show the answer
Answer:Nothing. The new content has a new hash, so a new URL, and the old URL's cached copy is simply never requested again. That is the cache-busting pattern: change the URL instead of purging.
2.Why is the game's manifest.json excluded from the immutable rule when every other game file is included?Show the answer
Answer:Its name never changes. A year-long promise only works when new content gets a new name, so the one fixed-name file must be allowed to be revalidated.
3.Name three things that stop Cloudflare from caching an otherwise eligible file.Show the answer
Answer:A Cache-Control of private, no-store, no-cache or max-age=0; a Set-Cookie header on the response; and a request that is not GET. HTML and JSON are also not cached by default.
Related deep dives on this site
Sources
Primary sources checked on the research date. Follow them for the current version.