Next.js ISR Revalidation Stuck on Stale Pages (Vercel, 2026)

An ISR page keeps serving old HTML past its revalidate window. Read the x-vercel-cache header, force an on-demand revalidate, and fix the real cause: edge shadowing, a failing background render, or a path mismatch.

You ship a content fix at 14:00. The page has revalidate: 60, so you expect new HTML by 14:01. At 14:30 it still serves the old title. Add ?t=${Date.now()} and you get fresh HTML; hit the canonical URL again and it is stale.

Fastest fix: run curl -sI "https://your-site.com/path" and read the x-vercel-cache header. If it says STALE, ISR is working — the next request after the background render finishes will be fresh, so just hit the URL twice more. If it says HIT with an age larger than your revalidate, the edge CDN (not ISR) is holding the page, and you need to force a revalidation with revalidatePath (see Step 2). One refetch usually decides which bucket you are in.

This is almost never ISR being “broken.” As of June 2026 the real causes are: the page is mid-regeneration and you are reading the expected STALE response too early; an edge or downstream CDN layer shadowing ISR with a long s-maxage; a stale prerender-manifest.json from a half-failed deploy; an on-demand revalidatePath call targeting a path that no longer matches the route; or a data fetch that threw during background revalidation, after which Next.js silently keeps serving the old page with no retry.

First: decode the x-vercel-cache header

Everything downstream depends on this one value. Vercel’s CDN sets x-vercel-cache on every response, and its official meanings (Vercel response-headers docs) tell you exactly which layer you are fighting:

ValueWhat it meansAre you stuck?
HITServed from the CDN, content still fresh.Only a problem if age exceeds your revalidate — then the edge TTL is too long.
STALEServed from cache, but expired, so a background regeneration was kicked off.No. This is healthy ISR. The NEXT request after regen finishes is fresh.
MISSNot in cache, fetched from origin. Normal for dynamic / runtime-cache routes.Usually no. A dynamic route legitimately shows MISS even when its data came from the runtime cache.
PRERENDERServed from static storage (e.g. fallback: true).No.
REVALIDATEDCache was deleted (a no-lifetime revalidatePath/revalidateTag), re-rendered in the foreground.No — this is the response right after an on-demand purge.
BYPASSCache was skipped entirely.The route is effectively dynamic; check why.

The most common false alarm: you ship a change, refresh once, see STALE, and conclude ISR is broken. It is not. STALE means the regeneration is already running in the background; refresh once or twice more and you should get the fresh page (then HIT). You are genuinely stuck only when (a) HIT with age far past revalidate, or (b) STALE that never flips to fresh because the background render keeps failing.

Note on x-nextjs-cache: that header is a Pages-Router-era signal. On modern Vercel deployments the canonical edge status is x-vercel-cache; trust it first.

Common causes

Ordered by what we see most often on Vercel + Next.js 13/14/15.

1. CDN edge cache hit shadowing the ISR layer

Vercel’s edge cache sits in front of Next.js ISR. If your route emits Cache-Control: public, s-maxage=3600 (from middleware, vercel.json, or a manual header), the edge holds the response for an hour regardless of your ISR revalidate: 60. The default Next.js-on-Vercel ISR headers are s-maxage={revalidate}, stale-while-revalidate=31536000; the bug is almost always something overriding that with a larger s-maxage.

Header priority matters: Vercel-CDN-Cache-Control is exclusive to Vercel and wins over CDN-Cache-Control, which wins over plain Cache-Control. If you set Cache-Control with no CDN-specific header, Vercel strips s-maxage and stale-while-revalidate before sending to the browser, so you cannot diagnose it from the browser alone — check from the server side with curl.

A downstream CDN is the other frequent culprit (new in 2026 setups): if you front Vercel with Cloudflare or another proxy and set an Edge TTL on the ISR route there, that proxy caches the stale HTML and never lets Vercel’s stale-while-revalidate cycle complete. Rule: never set an external Edge TTL on ISR routes; let Vercel’s s-maxage/stale-while-revalidate headers pass through untouched.

How to spot it: curl -sI https://your-site/path shows x-vercel-cache: HIT and age: close to 3600. Look at cache-control — if s-maxage is bigger than revalidate, the edge wins.

2. On-demand revalidatePath called with the wrong path

revalidatePath('/blog/[slug]') does NOT match /blog/my-post. You need either the literal path revalidatePath('/blog/my-post') or, for a dynamic route, the token form plus type: revalidatePath('/blog/[slug]', 'page') (App Router). The type second argument ('page' | 'layout') is required when the path contains a dynamic segment and omittable only for literal paths — this is the most common mistake.

How to spot it: Trigger the webhook that calls revalidatePath, then check the next request. A correct purge returns x-vercel-cache: REVALIDATED (then HIT with fresh content); if you still see HIT with the old HTML, the path never matched.

3. getStaticProps / RSC fetch threw during background revalidation

When ISR revalidation runs and the fetcher throws, Next.js silently logs and keeps serving the old page. There is no automatic retry. The page stays stale until either a successful background revalidation or a deploy.

How to spot it: Function logs show repeated errors from the page’s data fetch, but the URL keeps returning 200 with old content.

4. prerender-manifest.json drift between deploys

A deploy that fails partway can leave the prerender manifest pointing at a previous build’s HTML. New revalidations write to one location but reads serve from the prior one. Common after a failed deploy that was force-promoted.

How to spot it: Same URL serves different HTML across different edge regions (curl --resolve to two different IPs). Manifest is out of sync.

5. The route never entered the prerender manifest (cookies / headers / dynamic APIs)

For revalidate to do anything, the route must be registered in Vercel’s prerender manifest at build time. If a server component calls cookies(), headers(), or reads searchParams, Next.js opts the route out of static rendering, so it stays fully dynamic and your export const revalidate is a no-op. You think it is ISR; it is actually dynamic-but-cached, and the revalidation semantics differ.

How to spot it: Build log shows (λ) / (ƒ) / (d) next to that route, not (SSG) / (ISR) / (○). Vercel “Functions” tab shows invocations on the route on most requests, and x-vercel-cache reads MISS even outside any revalidate window.

6. Webhook hits the wrong region / deployment

revalidatePath only invalidates the deployment that received the call. If your webhook points to your-site.vercel.app but your domain serves a different deployment alias, the invalidation lands on a deployment nobody is reading.

How to spot it: Webhook returns 200 OK, but production stays stale. Check that the webhook URL is your production domain, not a preview or branch alias.

7. unstable_cache / fetch cache shadowing ISR

App Router pages use fetch with its own cache layer (force-cache, revalidate: 60 on the fetch). If both layers exist with mismatched values, the longer one wins.

How to spot it: Page-level revalidate = 60 but fetch(..., { next: { revalidate: 3600 } }) inside. The fetch wins.

Before you start

  • Confirm staleness via curl -I from outside any logged-in session (cookies opt routes out of ISR).
  • Compare timestamps: when did you ship the change, what is the page’s revalidate value, what is age in the response.
  • Know which router you are on: Pages Router (getStaticProps + revalidate) vs. App Router (export const revalidate / fetch revalidate / revalidateTag).
  • Have admin access to call revalidatePath manually as a forcing function.

Information to collect

  • Full response headers: cache-control, x-vercel-cache, x-nextjs-cache, age, x-vercel-id.
  • The page’s revalidate config and any fetch cache config inside.
  • Latest 10 minutes of function logs for the route (look for thrown errors during background revalidation).
  • The webhook payload + URL used for on-demand revalidation, plus its response code.
  • Whether the route uses cookies(), headers(), or searchParams.
  • Deployment ID currently aliased to production (vercel ls --prod).

Step-by-step fix

Ordered by ROI.

Step 1: Inspect headers to identify which cache layer holds the stale copy

curl -sI "https://your-site.com/blog/my-post"

Read these fields:

  • x-vercel-cache: STALE → expired but regenerating in the background. Refetch once or twice; the next response should be fresh. Not stuck.
  • x-vercel-cache: HIT with age larger than revalidate → the edge/CDN layer is holding it past the ISR window. This is the real bug.
  • x-vercel-cache: MISS → dynamic / runtime-cache route (not classic ISR). See cause 5.
  • age: 3500 → the response is 3500 seconds old, regardless of revalidate.
  • cache-control: ...s-maxage=N → the edge will hold for N seconds.
  • x-vercel-id: ... → the regions the request hit; useful when only some regions are stale.

If you keep seeing STALE, hit the URL a few more times spaced a couple of seconds apart. If it never becomes fresh, the background render is failing — jump to Step 4.

Step 2: Force a manual revalidation via on-demand API

Create a route handler:

// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const secret = req.nextUrl.searchParams.get("secret");
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
  const path = req.nextUrl.searchParams.get("path");
  if (!path) return NextResponse.json({ ok: false }, { status: 400 });
  revalidatePath(path);
  return NextResponse.json({ ok: true, revalidated: path });
}

Call it with the literal path:

curl -X POST "https://your-site.com/api/revalidate?secret=$REVALIDATE_SECRET&path=/blog/my-post"

Then re-fetch the URL. If it is fresh, on-demand works — wire it into your CMS webhook properly.

Step 3: Align s-maxage with revalidate

In App Router pages, do not set Cache-Control manually — Next.js does it correctly. If a middleware or response header is overriding it:

// middleware.ts — remove or correct any line like:
// response.headers.set("Cache-Control", "public, s-maxage=3600, ...");

If you need a custom header, match s-maxage to your revalidate:

response.headers.set(
  "Cache-Control",
  `public, s-maxage=60, stale-while-revalidate=86400`,
);

Step 4: Watch background revalidation for thrown errors

Stream live runtime logs while you trigger a revalidation. As of June 2026 the CLI uses --follow (shorthand -f) for live streaming, which runs for up to 5 minutes:

vercel logs --follow
# or pin a specific deployment:
vercel logs --follow --deployment dpl_xxxxx

Look for stacktraces during the revalidation window. If the data fetch is throwing, fix the upstream — Next.js will not retry on its own and will keep serving the old page indefinitely (this is the “STALE that never goes fresh” case). See vercel 500 errors for the matching server-side error class.

Step 5: Verify the App Router fetch cache is not overriding

Audit every fetch(...) inside the page:

// Make all top-level data fetches honor the page-level revalidate
const res = await fetch(url, { next: { revalidate: 60 } });

Or use revalidateTag so you can invalidate a logical group:

const res = await fetch(url, { next: { tags: ["post-list"], revalidate: 3600 } });
// elsewhere:
import { revalidateTag } from "next/cache";
revalidateTag("post-list");

Step 6: Check the production deployment alias

vercel ls --prod

Make sure your webhook URL hits the production hostname (your-site.com), not a preview alias like your-site-git-main-team.vercel.app. Revalidation is per-deployment; hitting the wrong one is silent.

Step 7: Bust the edge cache as a hard reset

If you must force-clear right now:

  • Deploy any small change (touch a comment) to trigger a fresh build — new builds get fresh edge caches.
  • Or purge from the dashboard: Project → Settings → Caches → invalidate (or delete) by tag. This is the supported on-demand purge path and maps to the same STALE/REVALIDATED states above.
  • For a clean known-good deployment, re-promote it: Project → Deployments → the deployment’s menu → Promote to Production.

Avoid making this routine; it papers over the real fix.

How to confirm it’s fixed

  • curl -sI against the path returns fresh content with x-vercel-cache: HIT (and any earlier STALE reliably flips to HIT within a few seconds of a request).
  • A POST to your /api/revalidate?path=... returns x-vercel-cache: REVALIDATED on the first refetch, then fresh content within 2-3 seconds.
  • Function logs show successful background revalidations (status 200, no thrown errors during the revalidate window).
  • A CMS edit + webhook test propagates to the live URL within the configured revalidate window — verify from a clean, logged-out session, since cookies can opt the route out of ISR.

Long-term prevention

  • Keep s-maxage equal to or smaller than revalidate everywhere; never let edge TTL exceed ISR window.
  • Wrap all data fetches in try/catch and return a sentinel rather than throw, so background revalidations never silently fail.
  • Use revalidateTag for logical groups ("posts", "site-config") rather than path-by-path.
  • Test the revalidate webhook from CI: deploy, edit a fixture, call the webhook, assert fresh HTML.
  • Log every revalidatePath / revalidateTag call with the path and result for forensic grep.
  • Standardize on either Pages Router OR App Router — mixing them creates two cache models that interact unpredictably.

Common pitfalls

  • Calling revalidatePath('/blog/[slug]') and expecting it to match all blog posts — without the App Router 'page' second arg it matches the literal string.
  • Setting revalidate = 0 “for safety” — that opts the route out of static generation entirely and you pay a function invocation on every request.
  • Forgetting that cookies() or headers() in a server component opts the whole route out of ISR. Move that work to a Route Handler.
  • Assuming edits to non-content code (a layout, a util) bust ISR — they only bust at deploy time, not at revalidate time.
  • Relying on the revalidate window during low-traffic periods — ISR triggers on request. A page with zero traffic never revalidates. See deploy succeeded page old for the related “static site looks old” pattern.

FAQ

Q: curl shows x-vercel-cache: STALE. Is my ISR broken?

No — STALE is the healthy state. It means the cached copy expired and Vercel already kicked off a background regeneration. Refetch once or twice (a couple of seconds apart) and you should get the new HTML, after which the header reads HIT. You only have a real problem if STALE never flips to fresh (background render is failing — check logs) or you see HIT with an age far past your revalidate (edge TTL too long).

Q: My page has revalidate: 60 but logs show no background revalidation. Why?

ISR background revalidation only fires when a request arrives AFTER the revalidate window. A page with zero traffic from minute 1 onward stays cached until traffic resumes. For low-traffic critical pages, schedule a synthetic ping (a cron that hits the URL) so the window has a request to ride.

Q: How do I invalidate everything at once?

revalidatePath('/', 'layout') invalidates the root layout and effectively all pages under it. Use sparingly — it forces a wave of regeneration.

Q: I deployed and the page still shows old content even with a fresh build.

Fresh builds reset prerender manifest but the CDN may still cache the previous response if s-maxage is long. Wait one s-maxage window or hit Vercel REST API to purge.

Q: Should I switch to fully dynamic rendering?

Only if revalidate fundamentally cannot model your needs (per-user, per-region). Dynamic is 10-100x more expensive in compute and slower TTFB. Fix the ISR plumbing first.

Tags: #Troubleshooting #Next.js #isr #cache #Vercel