Link to the code that reproduces this issue
https://github.com/alireza-attari/next-staletimes-bfcache-repro
To Reproduce
npm install && npm run build && npm start (config sets experimental.staleTimes: { dynamic: 60, static: 300 })
- Open
http://localhost:3000 and press Run reproduction (fully automated, ~80s; it logs every step with RSC request counts)
The harness shims window.fetch so RSC requests for /target (a force-dynamic page whose link is on screen, so its route tree is already prefetched) hang, clicks the link once while that request is in flight, restores fetch, then keeps clicking /target and logs whether the URL changed and how many RSC requests each click issued.
Run it a second time with npm run build:fixed && npm start (same app, staleTimes.dynamic unset) to compare.
Current vs. Expected behavior
Current, build with staleTimes.dynamic: 60 (verbatim from the harness log):
[t+3.0s] poisoning click: DEAD — URL unchanged after 3s, 1 RSC request(s) issued
[t+3.0s] step 3 — fetch shim REMOVED, network fully restored
[t+7.0s] click ~1s after restore: DEAD — URL unchanged after 3s, 0 RSC request(s) issued
[t+15.0s] click ~5s after restore: DEAD — URL unchanged after 3s, 0 RSC request(s) issued
[t+28.0s] click ~10s after restore: DEAD — URL unchanged after 3s, 0 RSC request(s) issued
[t+78.1s] click ~65s after the poisoning click: DEAD — URL unchanged after 5s, 1 RSC request(s) issued
Current, same scenario with staleTimes.dynamic unset: every post-restore click issues a fresh RSC request, and the navigation still never commits.
In both builds /target stays dead until a full page reload; nothing is logged, no error surfaces, and the /other control link keeps navigating normally in the same session — the poisoning is per-route.
Expected: a navigation whose payload fetch stalled or failed falls back to a full-document navigation, as the pre-segment-cache router did (it logged "Failed to fetch RSC payload … Falling back to browser navigation"). A later click with a healthy network should never be silently dropped, and certainly not dropped without issuing any request.
From reading the compiled dist in 16.3.3, three problems compound:
- A never-settling promise is stored in the BFCache.
writeToBFCache (client/components/segment-cache/ppr-navigations.js) runs during tree construction, before any response exists, so a navigation that adopted an in-flight prefetch entry (the Pending branch of readSegmentCacheEntryForNavigation) parks an unsettled rsc/prefetchRsc promise there.
- With
staleTimes.dynamic > 0 that poisoned entry is then served: readFromBFCacheDuringRegularNavigation (case FreshnessPolicy.Default) returns it with needsDynamicRequest: false, so the dropped clicks issue zero requests until staleAt = now + dynamic * 1000 expires — the failure is invisible in the network tab for exactly dynamic seconds. With the default 0 the entry is always expired and this branch never serves.
- Even when a fresh request is issued, React stays parked on the stale prefetch thenable:
layout-router.js renders useDeferredValue(cacheNode.rsc, resolvedPrefetchRsc), which suspends on prefetchRsc first, and a later dynamic response resolving cacheNode.rsc does not re-wake the transition. The failure is silent because navigateToUnknownRoute(...).catch(() => state) (segment-cache/navigation.js) swallows it.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.6.0
Available memory (MB): 18432
Available CPU cores: 11
Binaries:
Node: 22.23.1
npm: 10.9.8
Yarn: 1.22.22
pnpm: 11.22.0
Relevant Packages:
next: 16.3.3 // Latest available version is detected (16.3.3).
eslint-config-next: N/A
react: 19.2.8
react-dom: 19.2.8
typescript: 7.0.2
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Navigation
Which stage(s) are affected? (Select all that apply)
next start (local), Other (deployed production)
Additional context
We first hit this as recurring user reports in a production dashboard (~130 static-shell routes, heavy router.prefetch usage), with no fetch shims and no network fault involved: intermittent silently-dead sidebar clicks — zero in-flight requests, idle main thread, URL frozen — deterministic per route for a window, self-healing after exactly staleTimes.dynamic seconds. The heal window tracking the config value (60s) is what led us to the BFCache staleAt. Removing staleTimes.dynamic from the config eliminated the zero-request variant in production (36/36 navigations clean vs 6/24 silently dropped in the same measurement loop before). The fetch shim in the repro only makes the poisoning step deterministic.
Related reports:
Link to the code that reproduces this issue
https://github.com/alireza-attari/next-staletimes-bfcache-repro
To Reproduce
npm install && npm run build && npm start(config setsexperimental.staleTimes: { dynamic: 60, static: 300 })http://localhost:3000and press Run reproduction (fully automated, ~80s; it logs every step with RSC request counts)The harness shims
window.fetchso RSC requests for/target(aforce-dynamicpage whose link is on screen, so its route tree is already prefetched) hang, clicks the link once while that request is in flight, restoresfetch, then keeps clicking/targetand logs whether the URL changed and how many RSC requests each click issued.Run it a second time with
npm run build:fixed && npm start(same app,staleTimes.dynamicunset) to compare.Current vs. Expected behavior
Current, build with
staleTimes.dynamic: 60(verbatim from the harness log):Current, same scenario with
staleTimes.dynamicunset: every post-restore click issues a fresh RSC request, and the navigation still never commits.In both builds
/targetstays dead until a full page reload; nothing is logged, no error surfaces, and the/othercontrol link keeps navigating normally in the same session — the poisoning is per-route.Expected: a navigation whose payload fetch stalled or failed falls back to a full-document navigation, as the pre-segment-cache router did (it logged "Failed to fetch RSC payload … Falling back to browser navigation"). A later click with a healthy network should never be silently dropped, and certainly not dropped without issuing any request.
From reading the compiled dist in 16.3.3, three problems compound:
writeToBFCache(client/components/segment-cache/ppr-navigations.js) runs during tree construction, before any response exists, so a navigation that adopted an in-flight prefetch entry (the Pending branch ofreadSegmentCacheEntryForNavigation) parks an unsettledrsc/prefetchRscpromise there.staleTimes.dynamic > 0that poisoned entry is then served:readFromBFCacheDuringRegularNavigation(caseFreshnessPolicy.Default) returns it withneedsDynamicRequest: false, so the dropped clicks issue zero requests untilstaleAt = now + dynamic * 1000expires — the failure is invisible in the network tab for exactlydynamicseconds. With the default0the entry is always expired and this branch never serves.layout-router.jsrendersuseDeferredValue(cacheNode.rsc, resolvedPrefetchRsc), which suspends onprefetchRscfirst, and a later dynamic response resolvingcacheNode.rscdoes not re-wake the transition. The failure is silent becausenavigateToUnknownRoute(...).catch(() => state)(segment-cache/navigation.js) swallows it.Provide environment information
Which area(s) are affected? (Select all that apply)
Navigation
Which stage(s) are affected? (Select all that apply)
next start (local), Other (deployed production)
Additional context
We first hit this as recurring user reports in a production dashboard (~130 static-shell routes, heavy
router.prefetchusage), with no fetch shims and no network fault involved: intermittent silently-dead sidebar clicks — zero in-flight requests, idle main thread, URL frozen — deterministic per route for a window, self-healing after exactlystaleTimes.dynamicseconds. The heal window tracking the config value (60s) is what led us to the BFCachestaleAt. RemovingstaleTimes.dynamicfrom the config eliminated the zero-request variant in production (36/36 navigations clean vs 6/24 silently dropped in the same measurement loop before). The fetch shim in the repro only makes the poisoning step deterministic.Related reports:
staleTimes.dynamic > 0suppresses even the retry requests.useActionStatepermanently pending #96233 — the same production fingerprint ("zero in-flight network, permanent Suspense fallback, reload-only recovery", both with non-zerostaleTimes.dynamicin config), auto-closed for lacking a reproduction. This repro provides one.