You shipped a change. The database has it, the build succeeded, the deploy finished. You load the page and it shows the old content.
At this point most people start debugging the thing they just changed. Nine times out of ten on a Next.js site, nothing is broken. Before you touch anything, send one request and read one header:
curl -s -D - -o /dev/null https://example.com/the-page | grep -i x-nextjs-cache
That header separates "my deploy failed" from "the page is simply stale", and those are completely different problems. I have confused them three times in two days on this site, and twice I reached a wrong conclusion and had to walk it back.
What the values mean
x-nextjs-cache reports how the response you just received was produced:
HIT— served from the cache, and the cached entry is still considered fresh. What you see is what the cache holds.MISS— nothing in the cache for this path, so it was rendered now. The content is current.STALE— this is the one that fools you. The cached entry has passed its revalidation window, so the server handed you the old body and kicked off a regeneration in the background. Your next request will almost certainly be fresh.
Read that third one again, because it is the whole article. A STALE response is
not a failure. It is the cache doing exactly what it was configured to do: never make a visitor
wait for a rebuild. The cost is that the first person to ask for an expired page gets yesterday's
copy.
The trap, with the actual numbers
I had just fixed a metadata bug and deployed it. To verify, I crawled every URL in the sitemap and checked whether the new tag was present. Result: 175 of 177 pages correct, two pages missing the tag.
Two pages. That looks exactly like a real bug — some code path the fix missed. I was already forming a theory about which one.
Then I requested one of those two pages again and read the headers:
x-nextjs-cache: STALE
The page had not missed the fix. It was serving a copy generated before the deploy, while regenerating behind the scenes. The very next request returned the correct tag. There was no bug. There had never been a bug.
It gets worse, in an interesting way. Earlier the same day I ran two tools over the site back to back — a crawler, then a content scanner. The crawler reported dead links that the scanner, running two minutes later, could not find. Both were correct. The crawler's own requests were what expired the cache and triggered regeneration, so by the time the second tool ran it was reading freshly generated pages. The first tool measured the old world; the second measured the world the first tool had created.
If you take one habit from this: when a verification result surprises you, request the page a second time before you believe it.
What actually controls this
For a route segment, the revalidation window is an export:
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // seconds
That means: serve from cache for an hour; after the hour, the next request gets the stale copy and triggers a rebuild. It does not mean "rebuild every hour" — nothing regenerates if nobody asks. A page nobody visits stays stale indefinitely, which is why a low-traffic site can show week-old content and a busy one feels instant.
Two values are worth knowing:
export const revalidate = 0— never serve from the cache; render per request.export const dynamic = "force-dynamic"— the same effect, stated as intent.
A sitemap route handler with revalidate = 3600 is a good illustration. Publish a
post and it will not appear in sitemap.xml immediately. It appears within the hour,
with no deploy and no manual step. I watched this happen yesterday: the post went live, the
sitemap still listed 61 URLs, and an hour later it listed 62. Nothing was broken in between.
When a timer is the wrong tool
Time-based revalidation assumes the content changes on its own schedule. It is the wrong tool when you change something and need it live now — particularly when the change was made outside the application.
That is the case people hit most often and understand least. If you update rows with a SQL script, or edit content in a CMS that your app does not know about, the app has no idea anything happened. No amount of redeploying helps, because the deploy is not what is stale — the cache entry is.
For that, invalidate explicitly:
import { revalidatePath, revalidateTag } from "next/cache";
// after a write that the app performed
revalidatePath("/blog/the-slug");
revalidateTag("posts");
Two things catch people here. revalidatePath only runs on the server — in a route
handler or a server action — so calling it from a client component does nothing. And it marks the
entry stale rather than rebuilding immediately, so the very next request may still return the old
body with x-nextjs-cache: STALE. That is not a failure either.
What a deploy does and does not clear
This depends on your host more than on Next.js, which is why the answers you find online contradict each other.
On a platform that builds fresh and discards the old filesystem, a deploy effectively clears
everything. On a self-hosted setup where you extract a build over the previous one, whether the
on-disk cache survives depends on whether you deleted it. If your deploy copies files over the
top without removing .next/cache, entries can outlive the deploy that was supposed
to replace them.
Worth separating from a related failure that looks identical: on Windows, a running process
holds locks on files, so an extract over a live app silently skips them and reports success
anyway. That produces a genuinely half-updated app, not a stale cache — and the header will say
HIT or MISS rather than STALE. I covered that one, and the
deploy order that avoids it, in
the Next.js on IIS and Plesk write-up.
Three caches, and only one of them is this header
A lot of the contradictory advice online comes from people solving a different layer and assuming it was the same one. There are usually three between your render and the visitor's screen:
- The Next.js data and route cache — on your server, reported by
x-nextjs-cache. This is the onerevalidateandrevalidatePathcontrol. - A CDN or reverse proxy in front of it, obeying
cache-control. It can keep serving an old body long after Next.js has regenerated, andx-nextjs-cachewill never tell you, because the response may not have reached Next.js at all. - The browser. Cheap to rule out, so rule it out first.
Two more headers help you tell them apart. age is how many seconds an
intermediate cache has been holding the response — a large and growing age on a page
you keep requesting means something in front of your server is answering. And
x-nextjs-prerender tells you the page was prerendered rather than rendered per
request, which is useful when you expected a route to be dynamic and it is not.
The practical ordering: hard-refresh once to eliminate the browser, then compare
curl against what the browser shows. If curl is correct and the browser is not, it
is local. If both are wrong but x-nextjs-cache says MISS, you are
probably looking at something in front of Next.js, not Next.js itself.
Reading it without curl
In Chrome or Firefox DevTools, open the Network tab, tick Disable cache, reload, click
the document request — the very first one, not an asset — and read Response Headers. The value is
there on every request Next.js serves, so you can watch it flip from STALE to
HIT on a second reload without leaving the browser.
One caveat that wastes time: if you are looking at a client-side navigation rather than a full page load, there is no document request to inspect. Reload the URL directly.
A checklist for "is my change actually live?"
- Request the page twice. If the second response differs from the first, you were looking at a stale entry, not a bug.
- Read
x-nextjs-cache.STALEmeans wait and retry;HITorMISSwith wrong content means something real is wrong. - Check the content at the source. Hit your API or query the database directly. If the source is wrong, the cache is innocent.
- Decide which layer is stale. Content changed outside the app needs
revalidatePath, not a redeploy. Code changes need a deploy, and a timer will never fix them. - Only then start debugging.
Compiled things — components, exported metadata, redirects in
next.config, anything with a NEXT_PUBLIC_ prefix — always need a
rebuild, because they are baked in at build time. That last one catches people constantly; I
wrote about a related environment-variable trap in
how .env.local silently overrides
.env.production. If you are newer to the framework, the
complete Next.js
tutorial covers the rendering model this builds on.
The short version
A page showing old content is usually not a broken deploy. It is a cache entry doing its job, and one header tells you which. Checking it costs one request. Not checking it cost me two wrong conclusions in two days, and both times I was already three steps into debugging code that was working perfectly.







Join the conversation
No comments yet — be the first to share what you think.