Implementing Stale-While-Revalidate in Next.js

This guide is part of Stale-While-Revalidate at the Edge. It walks through a concrete task: serving a Next.js App Router Route Handler from the Vercel Edge cache with a stale-while-revalidate window, so the route returns instantly from cache and refreshes in the background.

The problem

You have a Route Handler that proxies a slow upstream API — say a product feed that takes 400 ms to fetch. Calling it on every request is too slow and hammers the upstream. A long cache TTL fixes the latency but ships stale prices. You want the route to serve a cached copy in single-digit milliseconds, treat the copy as fresh for a minute, then serve it stale for several minutes while Next.js refreshes it behind the scenes.

Root cause: which cache layer Next.js actually uses

The confusion here is that Next.js exposes several caching surfaces and they do not all behave the same on the Edge Runtime. The Data Cache (driven by fetch(..., { next: { revalidate } })) and the Route Handler response cache both ultimately emit CDN-Cache-Control / Vercel-CDN-Cache-Control directives that the Vercel global edge cache obeys. The stale-while-revalidate behavior is enforced by that CDN layer, not by your handler code. So the task reduces to: pin the handler to the Edge Runtime, set a revalidate window, and emit the right Cache-Control directives. Because the edge isolate is torn down after the response, you do not — and must not — try to run the background refresh yourself; the CDN does it.

Which layer honors which directive A request passes from the browser through the Vercel edge cache to the Route Handler isolate and on to the upstream API. Each layer reads a different directive, and only the edge cache performs background revalidation. One request, four caches, four different directives Browser reads Cache-Control public, max-age=0 must-revalidate Vercel edge cache reads CDN-Cache-Control s-maxage + SWR window serves stale, then refreshes Route Handler isolate runtime = "edge" revalidate = 60 emits both headers Upstream API 400 ms per call reached only on revalidation Next.js Data Cache next: { revalidate: 60 } Only the edge cache refreshes in the background — the isolate is gone by then
Two of these layers are yours to configure and one of them — the edge cache — is the only one that can serve a stale body, which is why local testing never shows the behavior.

Step-by-step

Step 1 — Pin the Route Handler to the Edge Runtime

Declare the runtime and a revalidation window at the top of the handler. revalidate is the freshness window in seconds.

// app/api/products/route.ts
export const runtime = "edge";
export const revalidate = 60; // fresh for 60s, then revalidated

Step 2 — Fetch the upstream with an explicit revalidate hint

Use the next.revalidate option on fetch so the Data Cache stores the upstream payload and reuses it across requests rather than re-fetching every time.

// app/api/products/route.ts
export async function GET(): Promise<Response> {
  const upstream = await fetch("https://api.example.com/products", {
    next: { revalidate: 60 },
  });

  const body = await upstream.text();
  return new Response(body, {
    status: upstream.status,
    headers: { "Content-Type": "application/json" },
  });
}

Step 3 — Emit explicit CDN-Cache-Control directives

To control the stale window precisely, set CDN-Cache-Control on the response. This targets the CDN only, leaving browser caching under your separate control.

// app/api/products/route.ts
return new Response(body, {
  status: upstream.status,
  headers: {
    "Content-Type": "application/json",
    // CDN: fresh 60s, serve stale up to 10min while revalidating, survive errors 1 day.
    "CDN-Cache-Control":
      "public, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400",
    // Browser: revalidate every request, but accept a brief stale window.
    "Cache-Control": "public, max-age=0, must-revalidate",
  },
});

Step 4 — Confirm cache status

Deploy and request the route twice. The first response carries x-vercel-cache: MISS; the second, within the stale window, carries HIT or STALE and returns in single-digit milliseconds.

curl -sI https://your-app.vercel.app/api/products | grep -i x-vercel-cache
x-vercel-cache across successive requests Successive requests to the same route report MISS, then HIT, then STALE once the freshness window closes, then HIT again after the background refresh completes. Past the stale window the entry is gone and the handler runs again. Successive requests with s-maxage=60, stale-while-revalidate=600 t = 0 s MISS handler runs, upstream fetched, response stored 410 ms t = 20 s HIT inside the freshness window, no upstream call 6 ms t = 75 s STALE stale body returned now, CDN refreshes behind it 7 ms t = 78 s HIT refresh landed, the entry is fresh again 6 ms t = 12 min MISS past s-maxage plus the stale window on a quiet route 420 ms
A single STALE in this ladder is the whole feature working; a route that never leaves MISS is usually one whose directives were overwritten downstream.

Step 5 — Add on-demand invalidation for correctness

TTL handles routine freshness, but when a product genuinely changes you want it gone now. Tag the fetch and call revalidateTag from a mutation route. This ties into tag-based invalidation.

// in the GET handler:
await fetch("https://api.example.com/products", {
  next: { revalidate: 60, tags: ["products"] },
});

// app/api/products/revalidate/route.ts
import { revalidateTag } from "next/cache";
export const runtime = "edge";

export async function POST(): Promise<Response> {
  revalidateTag("products");
  return new Response(null, { status: 204 });
}

Config snippet

If you prefer page-level caching over a Route Handler, the same directive applies via next.config.js headers:

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/api/products",
        headers: [
          {
            key: "CDN-Cache-Control",
            value: "public, s-maxage=60, stale-while-revalidate=600",
          },
        ],
      },
    ];
  },
};

Local vs production divergence

Behavior next dev (local) Vercel (production)
CDN cache Not present; every request hits the handler Global edge cache honors CDN-Cache-Control
x-vercel-cache header Absent MISS / HIT / STALE
Background revalidation Synchronous on next request CDN refreshes out of band
revalidateTag effect In-process Data Cache only Purges across the edge fleet
stale-if-error Not exercised Serves stale on upstream 5xx

The key trap: SWR looks like it “does nothing” locally because there is no CDN. Always validate the stale behavior in a preview deployment, not next dev.

Same route, local versus preview deployment In next dev both requests run the handler and no cache header appears, so stale serving is never observed. In a preview deployment the same requests report MISS then HIT, and a later request reports STALE. Identical code, identical requests, different observable behavior next dev — no CDN in the path 1st request → handler runs, ~400 ms 2nd request → handler runs, ~400 ms x-vercel-cache header absent entirely Nothing is broken — there is simply no shared cache to serve a stale copy preview deploy — edge cache in front 1st request → MISS, ~410 ms 2nd request → HIT, ~6 ms after 60 s → STALE, ~7 ms, refresh queued The only place the directives you wrote are actually read by anything
Judge a caching change only against the right column; a local run can confirm the headers are emitted but never that they are honored.

Vitest validation

Test the handler’s directive output in isolation — you are asserting the contract the CDN consumes, which is the part you control.

// app/api/products/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";

beforeEach(() => {
  vi.stubGlobal(
    "fetch",
    vi.fn(async () =>
      new Response(JSON.stringify([{ id: 1 }]), { status: 200 }),
    ),
  );
});

describe("GET /api/products", () => {
  it("emits a stale-while-revalidate CDN directive", async () => {
    const res = await GET();
    const cdn = res.headers.get("CDN-Cache-Control") ?? "";
    expect(cdn).toContain("s-maxage=60");
    expect(cdn).toContain("stale-while-revalidate=600");
  });

  it("returns the upstream payload", async () => {
    const res = await GET();
    expect(await res.json()).toEqual([{ id: 1 }]);
  });
});

Pitfalls

  • Testing SWR in next dev. There is no CDN locally, so stale serving never happens. Fix: validate in a preview deployment and read x-vercel-cache.
  • Setting only Cache-Control, not CDN-Cache-Control. Browsers and the CDN then share one directive and you lose independent control. Fix: set CDN-Cache-Control for the edge and a separate Cache-Control for the browser.
  • Caching authenticated responses. A handler reading cookies() will cache one user’s data for all. Fix: set export const dynamic = "force-dynamic" or skip caching on authenticated routes.
  • Forgetting runtime = "edge". The handler runs in the Node.js serverless runtime with different cache semantics and cold-start behavior. Fix: declare the runtime explicitly.
  • revalidate and s-maxage disagree. Conflicting windows produce surprising freshness. Fix: keep revalidate, s-maxage, and the next.revalidate hint numerically aligned.

Production deployment checklist

Frequently Asked Questions

Why does stale-while-revalidate not work in next dev?

next dev runs no CDN, so there is nothing to serve a stale copy or revalidate in the background — every request hits your handler directly. The stale behavior only appears once the Vercel global edge cache is in front of the route. Validate it in a preview deployment and inspect the x-vercel-cache response header.

What is the difference between Cache-Control and CDN-Cache-Control here?

Cache-Control is honored by browsers and shared caches alike, while CDN-Cache-Control (and Vercel-CDN-Cache-Control) targets only the CDN. Setting them separately lets you serve stale-while-revalidate from the edge while keeping the browser on a stricter policy, which is usually what you want for an API route.

Should I run the background refresh myself with waitUntil?

Not in this setup. When you delegate to CDN-Cache-Control/revalidate, the Vercel edge cache performs the background revalidation for you. Reach for waitUntil and the Cache API only when you need behavior the declarative layer cannot express, such as a fully custom cache key.

How do I invalidate immediately when a product changes?

Tag the upstream fetch with next.tags, then call revalidateTag("products") from a mutation route. This purges every cached entry carrying that tag across the edge fleet, so the next request fetches fresh data regardless of the remaining TTL.