You ship a content fix at 14:00. The page has revalidate: 60, so you expect new HTML on screen by 14:01. At 14:30 the page still serves the old title. You hit ?t=${Date.now()} and get fresh HTML. You hit the canonical URL again and get stale again. This is almost never ISR being “broken” — it is a CDN layer in front of ISR shadowing the cache, a stale prerender-manifest.json from the last build, an on-demand revalidatePath call that targeted a path that no longer matches the route, or a getStaticProps that threw during revalidation and Next.js silently kept the old page.
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 returns a Cache-Control: public, s-maxage=3600 (or your framework default), the edge holds the response for an hour regardless of your ISR revalidate: 60.
How to spot it: curl -I 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 the dynamic-route token form revalidatePath('/blog/[slug]', 'page') (App Router) — easy to get wrong.
How to spot it: Trigger the webhook that calls revalidatePath, then check the next request: x-nextjs-cache is still HIT and the page is stale.
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. Cookies / headers fragmenting the cache key
If the page is read via fetch with cookies() or headers() in App Router, Next.js opts out of static rendering for that path. You think it is ISR; it is actually dynamic-but-cached, and revalidation semantics differ.
How to spot it: Build log shows (λ) or (d) next to that route, not (SSG) / (ISR). Vercel “Functions” tab shows invocations on the route.
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 -Ifrom outside any logged-in session (cookies opt routes out of ISR). - Compare timestamps: when did you ship the change, what is the page’s
revalidatevalue, what isagein 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
revalidatePathmanually 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
revalidateconfig and anyfetchcache 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(), orsearchParams. - 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 -I "https://your-site.com/blog/my-post"
Read these fields:
x-vercel-cache: HIT→ edge cache served it.x-nextjs-cache: HIT→ Next.js ISR served it.age: 3500→ the response is 3500 seconds old, regardless ofrevalidate.cache-control: ...s-maxage=N→ the edge will hold for N seconds.
If x-vercel-cache: HIT and age exceeds your revalidate, the edge layer (not ISR) is the issue.
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
Tail function logs while you trigger a revalidation:
vercel logs --follow --since 5m
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. 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 use the Vercel REST API to purge:
POST /v6/deployments/<id>/promotefrom a clean deployment.
Avoid making this routine; it papers over the real fix.
Verify
curl -Iagainst the path showsx-vercel-cache: HITonly after a real revalidation window has passed and the page is fresh.- A POST to your
/api/revalidate?path=...followed by a refetch returns the new content within 2-3 seconds. - Function logs show successful background revalidations (status 200, no thrown errors).
- A CMS edit + webhook test propagates to the live URL within the configured
revalidatewindow.
Long-term prevention
- Keep
s-maxageequal to or smaller thanrevalidateeverywhere; 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
revalidateTagfor 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/revalidateTagcall 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()orheaders()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
revalidatewindow 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: 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.
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.