PPR/Cache Components: revalidateTag({ expire: 0 }) leaves prerendered dynamic-segment pages on their fallback shell — no self-heal, revalidatePath doesn't recover, only a rebuild does
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/etabard/next-ppr-expire0-fallback-shell-repro
To Reproduce
pnpm install && pnpm build && pnpm start(port 4311)- The build shows
◐ /a(Partial Prerender), and.next/server/app/a.htmlcontains the<h1>in the static HTML — the per-path shell is complete, with a Suspense fallback only around thesearchParamshole.
- The build shows
- In another terminal:
./repro.sh— it requests/aand classifies each response by whether the<h1>appears before the first streamed<div hidden id="S:...">segment (i.e. whether the content is part of the static shell or only streamed in afterwards). The cached value embedsprocess.pid, so a build-worker fill and a runtime regeneration are distinguishable in the served HTML. - Observed output (next 16.3.0):
1) baseline (build artifacts):
/a → FULL SHELL [fill marker: 18962-1]
/control → FULL SHELL [fill marker: 18962-3]
2) revalidateTag('products', { expire: 0 }):
3) /a keeps serving the fallback shell (checks over ~55s):
t+1s cumulative → FALLBACK SHELL ← bug
t+4s cumulative → FALLBACK SHELL ← bug
t+10s cumulative → FALLBACK SHELL ← bug
t+20s cumulative → FALLBACK SHELL ← bug
t+20s cumulative → FALLBACK SHELL ← bug
build artifact .next/server/app/a.html → still contains <h1> (intact, but not served)
4) control route (no dynamic segment, same purged tag) recovers a full shell:
/control → FULL SHELL [fill marker: 19020-1]
5) attempted repair with revalidatePath('/a'):
after revalidatePath, t+2s → FALLBACK SHELL ← bug
after revalidatePath, t+7s → FALLBACK SHELL ← bug
build artifact .next/server/app/a.html → still contains <h1> (intact, but not served)
The route is a plain dynamic segment (/[slug]) enumerated by generateStaticParams, wrapped in a root <Suspense> (required so non-enumerated slugs can render on demand), reading a "use cache" function tagged products. The revalidate endpoint calls revalidateTag('products', { expire: 0 }).
Current vs. Expected behavior
Current: after revalidateTag(tag, { expire: 0 }) for a tag used by a "use cache" function that feeds the static shell, every subsequent response for the prerendered path serves the fallback shell of /[slug] (the route's root Suspense fallback replaces the entire route content) with everything real relegated to the streamed segments. Within what we could observe, the degradation does not recover:
- no self-heal across repeated requests over ~55s in the script — and the same degraded shape persisted for hours on a deployed production app,
- the concrete entry is never rewritten: the script verifies that
.next/server/app/a.html(full shell) is intact on disk the whole time, yet it is never served again, - a subsequent
revalidatePath('/a'), checked at +2s and +7s, does not restore the full shell, - a rebuild restores it (until the next purge).
Correction (2026-08-17): an earlier revision of this issue claimed the same degradation applied to the docs-recommended
revalidateTag(tag, 'max'). That was a measurement artifact:compare-modes.shsent?mode=max, which the repro's route handler did not recognize, falling through to its{ expire: 0 }branch — so the "max" run re-measured{ expire: 0 }. The repro is fixed; with the realrevalidateTag(tag, 'max')the shell is preserved. The permanent degradation is specific to{ expire: 0 }.
./compare-modes.sh runs each mode from a pristine build and a fresh server (it asserts via lsof/ps which next-server (vX.Y.Z) is actually bound to the port before measuring):
| Call | Shell afterwards | Data freshness |
|---|---|---|
revalidateTag(tag, { expire: 0 }) |
fallback shell, persists | immediate |
revalidateTag(tag, 'max') |
full shell kept; regenerated in background | fresh in ~2–4 s (pid marker flips from build worker to server pool) |
revalidateTag(tag, 'seconds') |
full shell kept; regenerated in background | fresh in ~2–4 s |
revalidatePath('/a') after the purge |
does not repair | — |
Two controls behave correctly under the same purge:
/control— same"use cache"data and tag, but no dynamic segment and no root Suspense: it regenerates a full shell (fill marker moves from the build-worker pid to the server pool pid),- the profile forms (
'max'and'seconds') — both preserve the full shell and regenerate it in the background.
Expected: the PPR platform guide describes revalidation as regenerating the shell and the postponed state together. That is what we observe for /control and for the profile forms — so we would expect dynamic-segment routes to re-prerender into a full shell under { expire: 0 } as well, rather than being left on their fallback shell. If serving the fallback shell until some other mechanism rebuilds the entry is intended for this mode, it would be good to have that documented — as it stands, the entry never seems to be rebuilt at all.
Additional context:
- Reproduces on
next start— no platform/CDN involved. The same degraded shape (root-fallback shell + all content in streamed segments, then CDN-cached) has also been observed on Vercel deployments of a production app; that part is not exercised by the linked repository. - Impact:
{ expire: 0 }is a documented pattern for webhook-driven purges where the first visitor must see fresh content — therevalidateTagAPI reference recommends it for webhooks and third-party services that need immediate expiration. With it, a single CMS/commerce webhook leaves every affected prerendered page serving a skeleton-first response until the next deploy — LCP and perceived performance degrade sitewide for exactly the pages PPR is meant to make fast. The profile forms ('max','seconds') are a workaround where a brief stale window is acceptable. - Possibly related: #95379 (runtime-regenerated shells and metadata placement), #96289 (optional catch-all resume replay on Vercel — its plain
[slug]control case is healthy there, so this is a different failure).
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0
Available memory (MB): 49152
Available CPU cores: 14
Binaries:
Node: 22.15.0
npm: 10.9.2
pnpm: 10.33.0
Relevant Packages:
next: 16.3.0
react: 19.2.8
react-dom: 19.2.8
typescript: 5.9.3
Next.js Config:
output: N/A
Reproduced identically on next@16.3.0 (repro pinned), next@16.3.1 (re-run after pnpm add next@16.3.1) and next@16.3.1-canary.21 — each run from a fresh .next and a fresh next start, with the server process bound to the port asserted via lsof before measuring.
Which area(s) are affected? (Select all that apply)
Partial Prerendering (PPR), Caching (use cache / cacheComponents), Dynamic Routes
Which stage(s) are affected? (Select all that apply)
next start (local), Vercel (Deployed)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked next-ppr-expire0-fallback-shell-repro and run pnpm install && pnpm build && pnpm start, then inspect repro.sh and compare-modes.sh. Compare the dynamic /[slug] route, /control, and the revalidateTag and revalidatePath calls under each expiration mode. Done means { expire: 0 } restores and serves the complete prerendered shell for the dynamic route without requiring a rebuild, while the existing controls remain correct.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nextjs, react
- Domain
- performance, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100