Response headers: HTTP caching & compression
- Write a
Cache-Controlvalue for a requirement: browser vs shared cache (max-agevss-maxage),private/public,no-cachevsno-store,stale-while-revalidate - Explain revalidation with
ETag/If-None-Match→304, and whatVaryadds to a cache key - Explain who compresses a response (
Accept-Encoding→Content-Encoding;next start, nginx or the CDN) and check it with curl without fooling yourself - Predict which
Cache-Controlthe client gets whennext.configheaders()and a Route Handler both set it, onnext startand on Vercel - Prove CDN caching on Vercel from
x-vercel-cacheon a repeat request, not from theCache-Controlthe browser sees
Read
Two caches, two numbers — max-age and s-maxage
FlagBetween your server and the user sit two kinds of HTTP cache (RFC 9111):
- a private cache: the browser's, for one user;
- a shared cache: a CDN or reverse proxy, serving the same stored response to everyone.
Cache-Control talks to both, and the two most-used directives each pick an audience:
max-age=N: fresh for N seconds, in any cache.s-maxage=N: fresh for N seconds in shared caches only. It overridesmax-agethere, and browsers ignore it.
So one header can say "the CDN may keep this for a day, the browser must check every time":
// app/api/catalog/route.ts — the same JSON for every visitor
export async function GET() {
const catalog = await getCatalog();
return Response.json(catalog, {
headers: { "Cache-Control": "public, max-age=0, s-maxage=86400" },
});
}
The CDN answers from its copy for a day, so your function runs roughly once a day per CDN location instead of once per visitor (on Vercel, each new deployment starts with an empty cache). The browser keeps nothing fresh, so users never sit on an old copy the CDN has already replaced.
Four recipes cover most responses:
| Response | Header |
|---|---|
Hashed build asset (app.3f9a1c.js) | public, max-age=31536000, immutable |
| Same for every visitor, can be a little stale | public, max-age=0, s-maxage=86400 |
| Personal (a dashboard, "my orders") | private, max-age=0 |
| Must never be written to any cache (tokens, statements) | no-store |
private keeps shared caches out: a CDN must not store it, but the user's own browser may.
public is the reverse permission, and you rarely need it: a max-age or s-maxage already
makes a normal response cacheable. Its real job is overriding the rule that a shared cache won't
store the answer to a request that carried an Authorization header (s-maxage and
must-revalidate lift that rule too).
Fresh, stale, 304 and Vary — the life of a cached response
FlagA stored response is fresh while its age is below its lifetime, and stale after. RFC
9111 picks the lifetime in a fixed order: in a shared cache s-maxage first, then max-age,
then Expires minus Date, then a heuristic. Fresh responses are served without asking the
server. Stale ones are usually revalidated, not thrown away.
Revalidation with an ETag. The server labels each version of a resource; the cache sends
the label back:
$ curl -s -o /dev/null -D - -H 'If-None-Match: "v1"' http://127.0.0.1:4517/data
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=0, must-revalidate
ETag: "v1"
Vary: Accept-Encoding
Date: Sat, 17 Oct 2026 10:13:21 GMT
That transcript is from a 25-line node:http server (connection headers trimmed): when
If-None-Match matches the current ETag, it answers 304 with no body. RFC 9110 requires a 304 to repeat the headers a 200 would
have sent among Cache-Control, ETag, Vary and Date, so the cache can update its copy, and
forbids it from carrying content. A changed resource gets a normal 200 with a new ETag. Next.js
generates ETags for pages by default (generateEtags in next.config).
stale-while-revalidate=N (RFC 5861) lets a cache keep serving the stale copy for up to N
more seconds while it revalidates in the background:
Cache-Control: public, max-age=60, stale-while-revalidate=300
For the first 60 seconds: fresh, served directly. From 60 to 360: served stale instantly, with a
refresh in the background so the next visitor gets new content. After 360: the cache must wait
for the server. must-revalidate is the opposite promise: once stale, never serve without
checking.
Vary widens the cache key. By default a cache keys on method and URL. If the response
depends on a request header, name it:
Vary: Accept-Encoding
Now the cache stores a gzip copy and a brotli copy separately and never hands brotli to a client
that didn't ask for it. Choose Vary headers with few distinct values. Vary: Cookie makes
nearly every visitor a separate entry, and Vercel's CDN refuses to cache such responses
altogether. Per-user content belongs behind Cache-Control: private instead.
Three places set headers in Next.js 16 — and which one wins
FlagYou can set a response header in three places:
// 1. A Route Handler — this one response
return new Response(body, { headers: { "Cache-Control": "public, s-maxage=3600" } });
// 2. next.config.ts headers() — every response whose path matches `source`
async headers() {
return [{ source: "/:all*(pdf|csv)", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] }];
}
// 3. proxy.ts — responses that pass through it
const res = NextResponse.next();
res.headers.set("X-Frame-Options", "DENY");
return res;
And Next sets some itself. Pages get Cache-Control from how they render: a static page
s-maxage=31536000, a revalidating page s-maxage=<revalidate>, stale-while-revalidate=…, a
dynamic page private, no-cache, no-store, max-age=0, must-revalidate. Hashed files under
/_next/static get public, max-age=31536000, immutable, which the docs say you cannot
override. A page component itself has no API for response headers (headers() from
next/headers is read-only); when you need control over one response, that's a Route Handler.
The collision. Say that /:all*(pdf|csv) rule was written for files in public/. Later a
Route Handler serves /reports/q3.csv from storage and sends its own Cache-Control. Which one
does the client get?
next start: the config value. The router applies the config headers to the response first; when the handler'sResponseis written, Next 16'ssendResponseonly copies a header that isn't already set. The handler'sCache-Controlis dropped with no warning; its other headers survive. (Reproduced against Next 16.2's ownsend-response.js.)- Vercel: the handler's value. Vercel's Cache-Control docs state that
Cache-Controlreturned by a function takes priority over the same header fromnext.config.jsorvercel.json.
Same code, two answers. A route that sends no-store on its error response can have that
replaced by the config's max-age=3600 on a self-hosted server, so an outage gets cached for an
hour. Locally the page still renders fine, and no type check or lint rule notices.
The fixes:
- Before giving a Route Handler its own caching header, search
next.configheaders()for anysourcethat matches its URL, including wildcard and file-extension rules. - Fix overlaps in the config (narrow the
source), never by "setting it harder" in the route. - Assert response headers in a test against a running production build, and check the deployed platform once: it may merge differently.
Quiz
A response carries Cache-Control: public, max-age=60, s-maxage=3600. For how long may each cache use its copy without asking the server?
What does Cache-Control: no-cache tell a cache to do?
A Route Handler returns the signed-in user's own order history. The app sits behind a CDN. Which header?
FlagThe browser's copy of /api/settings (with ETag: "v7") has gone stale, and the resource hasn't changed. What happens on the next request?
Why does a compressed response carry Vary: Accept-Encoding?
You self-host with next start behind nginx, and want nginx to serve brotli. What should change in Next?
next.config has a headers() rule for /:all*(pdf|csv) setting Cache-Control: public, max-age=3600. A Route Handler at /reports/q3.csv returns Cache-Control: no-store. On a self-hosted next start, what does the client receive?
A Route Handler sends Cache-Control: public, s-maxage=86400. On Vercel, the browser's network tab shows no s-maxage at all. What does that tell you?
Fill in the blank
Let the CDN keep this catalog for a day while browsers always recheck.
Flagexport async function GET() { const catalog = await getCatalog(); return Response.json(catalog, { : { "Cache-Control": "public, max-age=0, =86400" }, }); }
A file whose name contains its content hash never changes. Tell caches not even to revalidate it on reload.
FlagCache-Control: public, max-age=31536000, Complete the conditional request and the server's answer when nothing changed.
FlagGET /api/settings HTTP/1.1 : "v7" HTTP/1.1 Not Modified ETag: "v7"
nginx in front of next start will do brotli. Stop Next from gzipping first.
const nextConfig: NextConfig = { : false, };
Give Vercel's CDN a one-hour lifetime without changing what browsers or other CDNs are told.
Flagheaders: { "Cache-Control": "max-age=10", "": "max-age=3600", }
Predict the output
# A server that supports both gzip and brotli, and compresses JSON $ curl -sI https://api.example.com/catalog | grep -i content-encoding
// next.config.ts
async headers() {
return [{ source: "/api/:path*", headers: [{ key: "Cache-Control", value: "no-store" }] }];
}
// app/api/catalog/route.ts
export async function GET() {
return Response.json(await getCatalog(), {
headers: { "Cache-Control": "public, s-maxage=600", "X-Catalog-Version": "42" },
});
}
// $ next build && next start
// $ curl -sI http://localhost:3000/api/catalogCache-Control: public, max-age=60, stale-while-revalidate=300 // Three CDN locations each stored this response at t = 0 s. // Each gets its next request at a different time: // Frankfurt t = 30 s // Tokyo t = 200 s // São Paulo t = 500 s
Challenge
Decide what a CDN may do with a response
Flagshared-cache.ts is the brain of a small CDN. Given the request, the stored response's
Cache-Control, and the copy's age in seconds, it returns one of four decisions:
| Decision | Meaning |
|---|---|
do-not-store | A shared cache must not keep this response |
fresh | Serve the stored copy; don't contact the origin |
serve-stale-and-refresh | Serve the stale copy now and refresh it in the background |
revalidate | Ask the origin (a conditional request) before reusing the copy |
The rules come from RFC 9111 (HTTP Caching) and RFC 5861 (stale-while-revalidate), with a few
simplifications: the cache reads only Cache-Control (no Expires); it never guesses a
lifetime, so a stored response without s-maxage or max-age is stale straight away; it stores
only GET/HEAD responses, as CDNs do (RFC 9110 allows caching a POST response in a narrow
case); and it treats the field-name forms private="…" and no-cache="…" like the plain ones.
It handles plain max-age correctly, and everything else the way people think caching works.
Five directives are misread: no-cache, private, the Authorization rule, s-maxage and
stale-while-revalidate. cache-control.ts already parses the header for you (directive names
lowercased, seconds() for numeric arguments).
Your task
Edit shared-cache.ts so the tests pass:
no-store,private, or a request that isn'tGET/HEAD→do-not-store- A request that carried
Authorizationis stored only when the response sayspublic,s-maxageormust-revalidate - The lifetime is
s-maxagewhen present, otherwisemax-age; an age below it →fresh, at or above it → stale no-cacheis stored, but every reuse isrevalidate- Stale but inside the
stale-while-revalidatewindow →serve-stale-and-refresh; past it →revalidate must-revalidateforbids serving stale: once stale, it'srevalidateeven inside the window
Further reading
- RFC 9111: HTTP Caching
- RFC 9110: HTTP Semantics (conditional requests, 304, Vary)
- RFC 5861: stale-while-revalidate
- MDN: HTTP caching
- MDN: Cache-Control
- MDN: Content-Encoding
- Next.js docs: headers (next.config.js)
- Next.js docs: compress
- Next.js docs: CDN Caching
- Vercel docs: Cache-Control headers
- Vercel docs: Cache status (x-vercel-cache)
- Vercel docs: CDN compression