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 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.
#!/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.
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:
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.
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
STALEforever. A route that returnsSTALEon 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.PRERENDERafter 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
HITwith largeAgein some regions only. - Tag mismatch. The webhook invalidated
post:7Ht2but the page was taggedarticle:7Ht2after a content type rename.NEXT_PRIVATE_DEBUG_CACHEoutput 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.