nextjs - ✅(Solved) Fix Next.js cache is poisoned when notFound() is triggered. [3 pull requests, 12 comments, 6 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#91321Fetched 2026-04-08 02:02:17
View on GitHub
Comments
12
Participants
6
Timeline
29
Reactions
1
Author
Assignees
Timeline (top)
commented ×12subscribed ×6cross-referenced ×3mentioned ×3

Error Message

  1. I go to /pokemon/1 manually and I can also sometimes get the 404 error.

Root Cause

  1. The second browser firefox fails the first test "Go to pokemon detail of bulbasaur" because it gets shown the 404 not-found.tsx page.

Fix Action

Fixed

PR fix notes

PR #1: fix(use-cache): prevent notFound()/redirect() from poisoning concurrent cache entries

Description (problem / solution / changelog)

Summary

Fixes #91321

When a "use cache" function throws a navigation error (notFound(), redirect()), the error was silently swallowed by the RSC error handler without being recorded in the live errors array. This caused bufferStream to complete normally, allowing DefaultCacheHandler to store the error-encoded RSC entry in memoryCache. Concurrent requests sharing the same cache key would then receive the poisoned entry instead of independently re-executing the cached function.

Root cause: createReactServerErrorHandler calls getDigestForWellKnownError first — if it returns a digest (for isNextRouterError errors like notFound()/redirect()), it returns early without invoking the onReactServerRenderError callback that pushes to errors. So errors stays empty, bufferStream drains cleanly, and the poisoned entry is written to memoryCache.

Fix: Wrap onError in both the renderToReadableStream and prerender calls inside generateCacheEntryImpl to explicitly push isNextRouterError errors into errors. This causes bufferStream to signal an error after draining, which makes DefaultCacheHandler.set() skip memoryCache.set(). Concurrent requests then independently re-execute the cached function and get a correct result.

Changes

  • packages/next/src/server/use-cache/use-cache-wrapper.ts — wrap onError in both renderToReadableStream (request/prerender-ppr/etc. path) and prerender (static prerender path) to push navigation errors to errors
  • test/e2e/app-dir/cache-components/ — add reproduction test (cache-components.not-found-race.test.ts) with HTTP barrier fixture to orchestrate the race condition

Test plan

  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts — new reproduction test passes
  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.test.ts — all 23 existing tests pass (no regressions)

Changed files

  • .agents/skills/router-act/SKILL.md (added, +274/-0)
  • AGENTS.md (modified, +1/-0)
  • crates/next-custom-transforms/src/transforms/react_server_components.rs (modified, +3/-3)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/app-dir/cache-life/output.stderr (modified, +2/-1)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/app-dir/cache-tag/output.stderr (modified, +2/-1)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/app-dir/root-params/output.stderr (modified, +2/-1)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/app-dir/server-only/output.stderr (modified, +2/-1)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/cache-life/output.stderr (modified, +2/-2)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/cache-tag/output.stderr (modified, +2/-2)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/root-params/output.stderr (modified, +2/-2)
  • crates/next-custom-transforms/tests/errors/react-server-components/client-graph/server-only/output.stderr (modified, +2/-2)
  • crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-api/output.stderr (modified, +39/-39)
  • crates/next-custom-transforms/tests/errors/react-server-components/server-graph/react-dom-api/output.stderr (modified, +15/-15)
  • docs/01-app/01-getting-started/10-error-handling.mdx (modified, +61/-1)
  • docs/01-app/03-api-reference/03-file-conventions/error.mdx (modified, +1/-0)
  • docs/01-app/03-api-reference/04-functions/catchError.mdx (added, +228/-0)
  • packages/next/error.d.ts (modified, +4/-0)
  • packages/next/errors.json (modified, +3/-1)
  • packages/next/src/client/components/catch-error.tsx (modified, +8/-13)
  • packages/next/src/compiled/http-proxy/index.js (modified, +5/-5)
  • packages/next/src/lib/constants.ts (modified, +1/-0)
  • packages/next/src/server/app-render/action-handler.ts (modified, +16/-9)
  • packages/next/src/server/app-render/postponed-state.test.ts (modified, +1/-0)
  • packages/next/src/server/app-render/work-unit-async-storage.external.ts (modified, +21/-3)
  • packages/next/src/server/lib/incremental-cache/memory-cache.external.ts (modified, +29/-8)
  • packages/next/src/server/lib/router-server.ts (modified, +3/-3)
  • packages/next/src/server/lib/router-utils/block-cross-site-dev.ts (renamed, +19/-18)
  • packages/next/src/server/request/root-params.ts (modified, +11/-3)
  • packages/next/src/server/resume-data-cache/cache-store.ts (modified, +49/-35)
  • packages/next/src/server/resume-data-cache/resume-data-cache.test.ts (modified, +3/-0)
  • packages/next/src/server/use-cache/use-cache-wrapper.ts (modified, +337/-117)
  • packages/next/src/server/web/spec-extension/unstable-cache.ts (modified, +1/-0)
  • patches/[email protected] (modified, +47/-3)
  • pnpm-lock.yaml (modified, +5/-5)
  • test/development/acceptance-app/rsc-build-errors.test.ts (modified, +7/-7)
  • test/development/acceptance-app/server-components.test.ts (modified, +2/-2)
  • test/development/acceptance/server-component-compiler-errors-in-pages.test.ts (modified, +29/-17)
  • test/development/app-dir/next-after-app-invalid-usage/index.test.ts (modified, +1/-1)
  • test/development/basic/allowed-dev-origins.test.ts (modified, +50/-0)
  • test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts (modified, +0/-13)
  • test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts (added, +71/-0)
  • test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js (added, +10/-0)
  • test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js (added, +21/-0)
  • test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js (added, +14/-0)
  • test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js (added, +27/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-build/app/[lang]/[locale]/use-cache/page.tsx (modified, +10/-8)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/conditional-on-another-cache/page.tsx (added, +68/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/conditional-on-root-param/page.tsx (added, +38/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/layout.tsx (added, +17/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/maybe-reads-root-param/page.tsx (added, +43/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/nested-in-unstable_cache/page.tsx (added, +30/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/unstable_cache/page.tsx (renamed, +3/-3)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/use-cache-resume/page.tsx (added, +36/-0)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[countryCode]/use-cache/page.tsx (renamed, +12/-10)
  • test/e2e/app-dir/app-root-params-getters/fixtures/use-cache-runtime/app/[lang]/[locale]/layout.tsx (removed, +0/-16)
  • test/e2e/app-dir/app-root-params-getters/use-cache.test.ts (modified, +218/-40)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/not-found.tsx (added, +3/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/page.tsx (added, +54/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/barrier/route.ts (added, +26/-0)
  • test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts (added, +57/-0)
  • test/e2e/app-dir/next-after-pages/index.test.ts (modified, +1/-1)
  • test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-10/page.tsx (modified, +14/-3)
  • test/e2e/app-dir/segment-cache/staleness/app/per-page-config/dynamic-stale-60/page.tsx (modified, +14/-3)
  • test/e2e/app-dir/segment-cache/staleness/app/per-page-config/hub-a/page.tsx (added, +30/-0)
  • test/e2e/app-dir/segment-cache/staleness/app/per-page-config/hub-b/page.tsx (added, +30/-0)
  • test/e2e/app-dir/segment-cache/staleness/app/per-page-config/hub-c/page.tsx (added, +30/-0)
  • test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts (modified, +57/-3)
  • test/production/app-dir/empty-shell-route-cache/app/layout.tsx (added, +9/-0)
  • test/production/app-dir/empty-shell-route-cache/app/page.tsx (added, +17/-0)
  • test/production/app-dir/empty-shell-route-cache/app/with-suspense/page.tsx (added, +18/-0)
  • test/production/app-dir/empty-shell-route-cache/app/without-suspense/page.tsx (added, +13/-0)
  • test/production/app-dir/empty-shell-route-cache/empty-shell-route-cache.test.ts (added, +92/-0)
  • test/production/app-dir/empty-shell-route-cache/next.config.js (added, +8/-0)
  • test/production/rewrite-request-smuggling/next.config.js (added, +13/-0)
  • test/production/rewrite-request-smuggling/pages/index.tsx (added, +3/-0)
  • test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts (added, +229/-0)
  • turbopack/crates/turbopack-core/src/data_uri_source.rs (modified, +8/-4)

PR #91505: fix(use-cache): prevent notFound()/redirect() from poisoning concurrent cache entries

Description (problem / solution / changelog)

Summary

Fixes #91321

When a "use cache" function throws a navigation error (notFound(), redirect()), the error was silently swallowed by the RSC error handler without being recorded in the live errors array. This caused bufferStream to complete normally, allowing DefaultCacheHandler to store the error-encoded RSC entry in memoryCache. Concurrent requests sharing the same cache key would then receive the poisoned entry instead of independently re-executing the cached function.

Root cause: createReactServerErrorHandler calls getDigestForWellKnownError first — if it returns a digest (for isNextRouterError errors like notFound()/redirect()), it returns early without invoking the onReactServerRenderError callback that pushes to errors. So errors stays empty, bufferStream drains cleanly, and the poisoned entry is written to memoryCache.

Fix: Wrap onError in both the renderToReadableStream and prerender calls inside generateCacheEntryImpl to explicitly push isNextRouterError errors into errors. This causes bufferStream to signal an error after draining, which makes DefaultCacheHandler.set() skip memoryCache.set(). Concurrent requests then independently re-execute the cached function and get a correct result.

Changes

  • packages/next/src/server/use-cache/use-cache-wrapper.ts — wrap onError in both renderToReadableStream (request/prerender-ppr/etc. path) and prerender (static prerender path) to push navigation errors to errors
  • test/e2e/app-dir/cache-components/ — add reproduction test (cache-components.not-found-race.test.ts) with HTTP barrier fixture to orchestrate the race condition

Test plan

  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts — new reproduction test passes
  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.test.ts — all 23 existing tests pass (no regressions)

Changed files

  • packages/next/src/server/use-cache/use-cache-wrapper.ts (modified, +13/-1)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/not-found.tsx (added, +3/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/page.tsx (added, +54/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/barrier/route.ts (added, +26/-0)
  • test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts (added, +57/-0)

PR #91510: fix(use-cache): prevent notFound()/redirect() from poisoning concurrent cache entries

Description (problem / solution / changelog)

Summary

Fixes #91321

When a "use cache" function throws a navigation error (notFound(), redirect()), the error was silently swallowed by the RSC error handler without being recorded in the live errors array. This caused bufferStream to complete normally, allowing DefaultCacheHandler to store the error-encoded RSC entry in memoryCache. Concurrent requests sharing the same cache key would then receive the poisoned entry instead of independently re-executing the cached function.

Root cause: createReactServerErrorHandler calls getDigestForWellKnownError first — if it returns a digest (for isNextRouterError errors like notFound()/redirect()), it returns early without invoking the onReactServerRenderError callback that pushes to errors. So errors stays empty, bufferStream drains cleanly, and the poisoned entry is written to memoryCache.

Fix: Wrap onError in both the renderToReadableStream and prerender calls inside generateCacheEntryImpl to explicitly push isNextRouterError errors into errors. This causes bufferStream to signal an error after draining, which makes DefaultCacheHandler.set() skip memoryCache.set(). Concurrent requests then independently re-execute the cached function and get a correct result.

Changes

  • packages/next/src/server/use-cache/use-cache-wrapper.ts — wrap onError in both renderToReadableStream (request/prerender-ppr/etc. path) and prerender (static prerender path) to push navigation errors to errors
  • test/e2e/app-dir/cache-components/ — add reproduction test (cache-components.not-found-race.test.ts) with HTTP barrier fixture to orchestrate the race condition

Test plan

  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts — new reproduction test passes
  • NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=start pnpm testheadless test/e2e/app-dir/cache-components/cache-components.test.ts — all 23 existing tests pass (no regressions)

Changed files

  • packages/next/src/server/use-cache/use-cache-wrapper.ts (modified, +13/-1)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/not-found.tsx (added, +3/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/[id]/page.tsx (added, +54/-0)
  • test/e2e/app-dir/cache-components/app/cases/not-found-race/barrier/route.ts (added, +26/-0)
  • test/e2e/app-dir/cache-components/cache-components.not-found-race.test.ts (added, +57/-0)

Code Example

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6031
  Available memory (MB): 36864
  Available CPU cores: 14
Binaries:
  Node: 24.12.0
  npm: 11.6.2
  Yarn: N/A
  pnpm: N/A
Relevant Packages:
  next: 16.2.0-canary.95 // Latest available version is detected (16.2.0-canary.95).
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  output: N/A
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

https://codesandbox.io/p/devbox/bold-breeze-8dykdw

To Reproduce

The link of the reproduction codesandbox does not actually trigger the bug. But shows the situation.

This is the scenario / bug:

  1. I have three playwright e2e tests: a. Go to pokemon detail of bulbasaur b. Go to detail of nonexisting pokemon to test 404 this triggers the notFound() c. create pokemon via form client component which calls a server function that calls updateTags

  2. Playwright runs the tests in chrome, firefox and safari

  3. The second browser firefox fails the first test "Go to pokemon detail of bulbasaur" because it gets shown the 404 not-found.tsx page.

  4. I go to /pokemon/1 manually and I can also sometimes get the 404 error.

I can only reproduce this locally with playwright, when it is running tests very rapidly.

What seems to happen is that there is a timing issue, somehow a 404 cache is assigned to a page that did not trigger the notFound(). I call this the poisoning of the cache. In other words /pokemon/1 seems to render the 404 result of /pokemon/1337.

Current vs. Expected behavior

I expect that notFound 404 pages are cached properly, and that they do not bleed over to pages that result in a 200.

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T6031
  Available memory (MB): 36864
  Available CPU cores: 14
Binaries:
  Node: 24.12.0
  npm: 11.6.2
  Yarn: N/A
  pnpm: N/A
Relevant Packages:
  next: 16.2.0-canary.95 // Latest available version is detected (16.2.0-canary.95).
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

cacheComponents, Use Cache, Not Found

Which stage(s) are affected? (Select all that apply)

next start (local), next dev (local)

Additional context

I see this behavior in both 16.2.0-canary.95 and 16.1.0

extent analysis

TL;DR

  • The issue can be mitigated by disabling caching for the notFound page or implementing a cache invalidation strategy to prevent cache poisoning.

Guidance

  • Review the caching configuration for the notFound page to ensure it's not being cached or has a very short cache lifetime.
  • Investigate the use of cache tags or cache keys to prevent cache collisions between different pages.
  • Consider implementing a cache invalidation strategy when the notFound page is rendered to prevent cache poisoning.
  • Verify that the issue is resolved by running the Playwright tests with caching disabled or with the proposed cache invalidation strategy.

Example

  • No code snippet is provided as the issue is more related to caching configuration and strategy rather than a specific code implementation.

Notes

  • The issue seems to be related to the caching behavior of Next.js, specifically with the notFound page.
  • The problem is only reproducible when running Playwright tests rapidly, which suggests a timing-related issue.
  • Disabling caching or implementing a cache invalidation strategy may have performance implications and should be thoroughly tested.

Recommendation

  • Apply workaround: Implement a cache invalidation strategy to prevent cache poisoning, as upgrading to a fixed version is not clearly implied in the issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING