vercel / vercel/next.js

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

Open
#97,457 1 comment 0 reactions 0 assignees View on GitHub

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
  1. pnpm install && pnpm build && pnpm start (port 4311)
    • The build shows ◐ /a (Partial Prerender), and .next/server/app/a.html contains the <h1> in the static HTML — the per-path shell is complete, with a Suspense fallback only around the searchParams hole.
  2. In another terminal: ./repro.sh — it requests /a and 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 embeds process.pid, so a build-worker fill and a runtime regeneration are distinguishable in the served HTML.
  3. 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.sh sent ?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 real revalidateTag(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 — the revalidateTag API 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.