Next.js Served Yesterday's Prices: Untangling the Four Caching Layers
The hardest Next.js bugs aren't crashes. Crashes have stack traces. The hardest ones are the quiet lies — pages that render perfectly, pass every test, and show data that stopped being true hours ago.
There is no "the cache" — there are four
Between your data source and the user's screen, App Router Next.js can interpose four distinct caches, each with its own scope, lifetime, and invalidation story:
- Request memoization — deduplicates identical
fetchcalls within a single render pass. Harmless; dies with the request. - Data Cache — persists
fetchresponses on the server across requests and deployments. This one outlives your intuitions. - Full Route Cache — stores the rendered HTML + RSC payload of statically rendered routes at build time.
- Router Cache — a client-side cache of RSC payloads, per session, so back/forward navigation is instant.
Our stale prices were a stack of three of them. The price list was fetched with a plain fetch() (cached in the Data Cache), on a route that was statically rendered at the last deploy (Full Route Cache), and traders— pharmacists navigating back to it got the client Router Cache copy. Three layers, all confidently serving the same stale answer.
Layer by layer: how the truth escapes
The Data Cache doesn't care about your deploy
The bit that surprised everyone in the incident review: the Data Cache persists across deployments. Redeploying does not flush cached fetch responses. If you assumed "we deploy nightly, so nothing is more than a day old" — no. A cached fetch with no revalidation config can outlive the code that made it.
The fix is to be explicit about freshness on every fetch that matters:
// Time-based: acceptable staleness is a business decision, write it down
const res = await fetch(`${API}/contract-prices/${accountId}`, {
next: { revalidate: 300, tags: [`prices-${accountId}`] },
});
And then make the repricing job push the invalidation instead of praying to the timer:
// app/api/reprice-webhook/route.ts
import { revalidateTag } from "next/cache";
export async function POST(req: Request) {
const { accountIds } = await verifySignedPayload(req);
for (const id of accountIds) {
revalidateTag(`prices-${id}`);
}
return Response.json({ revalidated: accountIds.length });
}
We wired the 2am repricing job to call this webhook when it finished. Tag-based invalidation turned "prices are stale for up to N hours" into "prices are stale for the duration of one HTTP round trip."
The Full Route Cache freezes the whole page
Even with fresh data-layer fetches, a statically rendered route serves its build-time HTML until revalidated. For the ordering portal, prices were per-account anyway — the page had no business being static:
// app/(portal)/catalog/page.tsx
import { connection } from "next/server";
export default async function CatalogPage() {
await connection(); // opt this route out of static rendering
const prices = await getContractPrices();
return <Catalog prices={prices} />;
}
(export const dynamic = "force-dynamic" does the same job as route-level config; the newer connection() API expresses it at the point of use. Check which your Next version supports — this API has moved around between majors.)
The Router Cache lies to people who navigate
The client-side Router Cache made our bug sticky: a pharmacist who visited the catalog, went to their cart, and came back got the cached RSC payload without a server round trip. After any mutation that changes what other pages show, purge it:
"use server";
export async function addToCart(formData: FormData) {
await persistCartLine(formData);
revalidateTag(`prices-${formData.get("accountId")}`);
revalidatePath("/catalog"); // also refreshes the client router cache entry
}
The mental model that sticks
The framing that finally made this click for my team: caching in Next.js is opt-out at the layers you don't see, and opt-in at the layers you do. You see your fetch calls, so you remember to think about them. You don't see the Full Route Cache or the Router Cache — there's no line of code to review — so they're where stale data hides.
For every route, answer three questions in the PR description:
- Is this route static or dynamic — and did I choose that, or did the heuristics choose for me?
- For every fetch: what invalidates it, and is that a timer or an event?
- After every mutation: which paths and tags does it revalidate?
The portal incident closed with a one-page "cache contract" doc per route. It felt bureaucratic. It has also prevented every recurrence since, which is more than I can say for most architecture documents I've written.