Next.js & the Network
Next.js & the Network

Response headers: HTTP caching & compression

Full · ~45 min
After this unit you can
  • Write a Cache-Control value for a requirement: browser vs shared cache (max-age vs s-maxage), private/public, no-cache vs no-store, stale-while-revalidate
  • Explain revalidation with ETag / If-None-Match → 304, and what Vary adds 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-Control the client gets when next.config headers() and a Route Handler both set it, on next start and on Vercel
  • Prove CDN caching on Vercel from x-vercel-cache on a repeat request, not from the Cache-Control the browser sees

Read

Simple
1 / 2

Two caches, two numbers — max-age and s-maxage

Flag

Between 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 overrides max-age there, 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:

ResponseHeader
Hashed build asset (app.3f9a1c.js)public, max-age=31536000, immutable
Same for every visitor, can be a little stalepublic, 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).

Medium
1 / 2

Fresh, stale, 304 and Vary — the life of a cached response

Flag

A 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.

Complex
1 / 2

Three places set headers in Next.js 16 — and which one wins

Flag

You 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's Response is written, Next 16's sendResponse only copies a header that isn't already set. The handler's Cache-Control is dropped with no warning; its other headers survive. (Reproduced against Next 16.2's own send-response.js.)
  • Vercel: the handler's value. Vercel's Cache-Control docs state that Cache-Control returned by a function takes priority over the same header from next.config.js or vercel.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.config headers() for any source that 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?

Flag

What does Cache-Control: no-cache tell a cache to do?

Flag

A Route Handler returns the signed-in user's own order history. The app sits behind a CDN. Which header?

Flag

The browser's copy of /api/settings (with ETag: "v7") has gone stale, and the resource hasn't changed. What happens on the next request?

Flag

Why does a compressed response carry Vary: Accept-Encoding?

Flag

You self-host with next start behind nginx, and want nginx to serve brotli. What should change in Next?

Flag

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?

Flag

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?

Flag

Fill in the blank

Let the CDN keep this catalog for a day while browsers always recheck.

Flag
export 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.

Flag
Cache-Control: public, max-age=31536000, 

Complete the conditional request and the server's answer when nothing changed.

Flag
GET /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.

Flag
const nextConfig: NextConfig = {
  : false,
};

Give Vercel's CDN a one-hour lifetime without changing what browsers or other CDNs are told.

Flag
headers: {
  "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
Flag
// 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/catalog
Flag
Cache-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
Flag

Challenge

Decide what a CDN may do with a response

Flag

shared-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:

DecisionMeaning
do-not-storeA shared cache must not keep this response
freshServe the stored copy; don't contact the origin
serve-stale-and-refreshServe the stale copy now and refresh it in the background
revalidateAsk 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't GET/HEAD → do-not-store
  • A request that carried Authorization is stored only when the response says public, s-maxage or must-revalidate
  • The lifetime is s-maxage when present, otherwise max-age; an age below it → fresh, at or above it → stale
  • no-cache is stored, but every reuse is revalidate
  • Stale but inside the stale-while-revalidate window → serve-stale-and-refresh; past it → revalidate
  • must-revalidate forbids serving stale: once stale, it's revalidate even inside the window

Loading the in-browser editor…

Further reading