Debugging Stale ISR Pages with x-nextjs-cache Headers

This is the troubleshooting companion to Next.js ISR Implementation: a repeatable procedure for finding which of four caches is holding an old version of a CMS page, using nothing but response headers and a CMS API call.

“The page didn’t update” is the most common ISR support request, and it has at least six distinct causes: a webhook that never fired, a webhook that failed verification, a revalidation that fetched the previous revision from the CMS CDN, a CDN that kept its copy, a browser cache, and a page that inherited a longer window than expected. Each leaves a different fingerprint in the headers. Reading them in order finds the culprit in minutes instead of a morning of guesswork.

The four places an old version can hideBrowser, CDN, Next.js cache and CMS delivery API, each with the header or check that reveals whether it holds the old version.Browsercheck: hard reload, DevTools size columnCache-Controldisk cacheCDNcheck: Age vs publish timecf-cache-statusx-cacheAgeNext.js cachecheck: HIT, STALE, MISSx-nextjs-cachex-vercel-cacheCMS delivery APIcheck: version equals the published onesys.revision_revupdatedAt
Work from the bottom up: prove the CMS serves the new version first, then move one tier closer to the reader.

The Problem

An editor at a retail brand updates the hero headline on the spring landing page in Storyblok. Twenty minutes later the old headline is still live in some regions and new in others. The team’s instinct is to redeploy, which clears the Next.js cache, and the problem disappears until the next publish. Nobody learns where the stale copy was, so it happens again, and redeploying for content changes quietly becomes part of the editorial workflow.

The fix is a diagnosis procedure that starts from the source of truth and moves outward, checking one tier at a time. Stop at the first tier that holds the old version: that tier, or the webhook that should have cleared it, is the fault.

How to Read the Headers

x-nextjs-cache is set by a self-hosted Next.js server on ISR and cached routes. HIT means the response came from the cache and is within its window. STALE means the response came from the cache after the window elapsed, and a background regeneration was triggered by this request. MISS means the page was rendered for this request, either because it was never cached or because it was invalidated. On Vercel, the equivalent is x-vercel-cache with similar values, plus PRERENDER for pages served from the build output.

Age is added by shared caches and counts seconds since the CDN stored the response. If Age is larger than the time since the publish, the CDN copy predates the publish, whatever Next.js did.

CDN status headers name the tier that answered: cf-cache-status on Cloudflare, x-cache on CloudFront and Fastly, often with the POP code. A HIT with a large Age right after a publish means the purge did not arrive.

Cache-Control on the response tells you what the CDN was allowed to do. An ISR route normally returns s-maxage=<window>, stale-while-revalidate. If you see s-maxage=31536000, the route was treated as fully static and only a purge or deploy will refresh the CDN copy.

Implementation

The script below runs the whole procedure for one URL and one entry. It checks the CMS version, then requests the page through the CDN and directly from the origin, and prints each tier’s verdict. Adapt the CMS query for your platform; the Storyblok and Contentful variants are both shown.

Bash
#!/usr/bin/env bash
# isr-trace.sh <public-url> <origin-url> <cms-entry-id>
set -euo pipefail
PUBLIC_URL="$1"; ORIGIN_URL="$2"; ENTRY="$3"

echo "== 1. CMS delivery API (source of truth)"
# Storyblok: published version and its update time
curl -s "https://api.storyblok.com/v2/cdn/stories/${ENTRY}?token=${STORYBLOK_TOKEN}&version=published&cv=$(date +%s)" \
  | jq '{id: .story.id, published_at: .story.published_at}'
# Contentful alternative:
# curl -s -H "Authorization: Bearer $CDA_TOKEN" "https://cdn.contentful.com/spaces/$SPACE/entries/$ENTRY" | jq '.sys | {revision, updatedAt}'

echo "== 2. Origin (Next.js cache, CDN bypassed)"
for i in 1 2; do
  curl -s -o /dev/null -D - "$ORIGIN_URL" -H "Cache-Control: no-cache" \
    | grep -iE '^(x-nextjs-cache|x-vercel-cache|cache-control|date):'
  sleep 2
done

echo "== 3. Public URL (through the CDN)"
curl -s -o /dev/null -D - "$PUBLIC_URL" \
  | grep -iE '^(age|cf-cache-status|x-cache|x-served-by|cache-control|x-nextjs-cache):'

echo "== 4. Content check: does the page contain the new text?"
curl -s "$PUBLIC_URL" | grep -c "${EXPECTED_TEXT:-}" || true

Requesting the origin twice matters. The first request after an invalidation often returns MISS or STALE and triggers regeneration; the second shows what the cache now holds. If the second request still serves old content with HIT, the regeneration fetched old data from the CMS, which is the CDN race described in the on-demand revalidation guide.

Diagnosis decision treeCheck the CMS version first, then the origin's second response, then the CDN Age header, and finally the browser; each negative answer names the faulty tier.CMS returnsnew version?Not published yetor wrong environmentOrigin 2nd requestnew content?Webhook missing, rejectedor fetched old revisionCDN Age <time since publish?CDN not purgedcheck purge logsBrowser cachehard reloadnoyesnoyesnoyes
The first tier that still holds the old version is where to look; everything below it is already correct.

A Worked Trace

Here is the trace from the Storyblok landing page in the problem statement, run eleven minutes after the publish. The CMS step returned a published_at of 09:41:07, so the source of truth was fine. The origin step printed:

Text
x-nextjs-cache: STALE
cache-control: s-maxage=300, stale-while-revalidate=31535700
date: Thu, 17 Sep 2026 09:52:14 GMT
---
x-nextjs-cache: HIT
cache-control: s-maxage=300, stale-while-revalidate=31535700
date: Thu, 17 Sep 2026 09:52:16 GMT

The second origin response contained the new headline, so the Next.js cache was correct, and the first response shows that nothing had revalidated the page before this request. The webhook had not reached the application at all. The public step printed cf-cache-status: HIT with age: 657, a copy stored at 09:41:19, twelve seconds after the publish. Cloudflare had cached the old page just before any regeneration happened and had received no purge.

The fix therefore had two parts. The Storyblok webhook pointed at a preview deployment URL, left over from testing, so it was moved to the production domain. And a Cloudflare tag purge was added after revalidateTag. With the old configuration the page would have stayed stale for the full edge TTL of 30 minutes. After the fix, the next publish reached every region in under five seconds.

Timeline of the stale landing page incidentThe publish at 09:41:07, the CDN caching the old page twelve seconds later, the trace at 09:52, and the edge TTL that would have expired at 10:11 without intervention.CMS: new version liveNext.js: not revalidatedno webhook reached prodCDN: old copy cacheds-maxage 1800, no purge0 s500 s1000 s1500 spublishtrace run
The CDN stored the old page twelve seconds after the publish; without a purge it would have served it for the full 30-minute TTL.

Configuration Reference

Header Values Meaning for a stale page
x-nextjs-cache HIT / STALE / MISS Next.js cache state for this request, self-hosted.
x-vercel-cache HIT / STALE / MISS / PRERENDER Same on Vercel; PRERENDER means build output, never revalidated since deploy.
Age seconds Time the CDN has held this copy; compare with time since publish.
cf-cache-status HIT / MISS / EXPIRED / REVALIDATED / DYNAMIC Cloudflare’s verdict; DYNAMIC means it did not cache at all.
x-cache Hit from cloudfront, HIT, MISS CloudFront or Fastly; Fastly lists shield and edge results.
Cache-Control s-maxage, stale-while-revalidate What the CDN was allowed to cache; very long values indicate a static route.

Enable NEXT_PRIVATE_DEBUG_CACHE=1 on a staging server to log every cache get, set and revalidation with its key and tags. It is noisy, but it shows directly whether the tag your webhook invalidated is one of the tags the page was stored with.

Gotchas & Edge Cases

  • STALE forever. A route that returns STALE on every request is regenerating but failing each time, so the old page survives. Check server logs for errors thrown during regeneration; the CMS may be rate-limiting you.
  • PRERENDER after a publish on Vercel. The page was generated at build time and has never been revalidated, which means no webhook reached it. Confirm the webhook targets the production domain, not a preview URL.
  • Correct origin, stale public page, small Age. The CDN refetched recently but got a stale response, usually from an origin shield that was not purged. Purge the shield tier too.
  • Different results per region. Run the public request from several regions, or pass a POP-selection header if your CDN supports one. A purge that has not propagated shows as HIT with large Age in some regions only.
  • Tag mismatch. The webhook invalidated post:7Ht2 but the page was tagged article:7Ht2 after a content type rename. NEXT_PRIVATE_DEBUG_CACHE output shows the stored tags; compare them with the webhook log.

Frequently Asked Questions

Why does the first request after publishing still show old content?

With stale-while-revalidate semantics, the request that triggers regeneration receives the cached page, marked STALE, and the regenerated page is served from the next request on. This is expected. If the second request is also old, one of the faults above applies.

Can I force a single page to refresh without a webhook?

Call your revalidation route manually with a signed request for the page’s tags, or temporarily use revalidatePath from a protected admin action. Avoid redeploying for content fixes, because it hides the real fault and invalidates every page at once.

How do I rule out the browser cache quickly?

Open the page in a private window or run the public curl step from the script. curl has no cache, so if it returns the new content while your browser shows the old one, the stale copy is local. Check the Cache-Control header: HTML should normally carry max-age=0 for browsers, so this case points to a service worker or an overly long browser max-age.

Does x-nextjs-cache appear on dynamic routes?

No. Fully dynamic routes are rendered on every request and are not stored in the ISR cache, so the header is absent. If you expected ISR and see no header, something on the route opted it into dynamic rendering, such as reading cookies or headers or using a zero window.