Skip to content

fix: skip worker forwarding when action was already forwarded#93710

Closed
elishagreenwald wants to merge 2 commits into
vercel:canaryfrom
elishagreenwald:fix/action-peer-forward-84504
Closed

fix: skip worker forwarding when action was already forwarded#93710
elishagreenwald wants to merge 2 commits into
vercel:canaryfrom
elishagreenwald:fix/action-peer-forward-84504

Conversation

@elishagreenwald

@elishagreenwald elishagreenwald commented May 8, 2026

Copy link
Copy Markdown
Contributor

What?

Stops the server action handler from forwarding to another worker when the incoming request was already forwarded (x-action-forwarded: 1).

Why?

When middleware rewrites a Server Action POST to a page that doesn't bundle the action, the receiving worker forwards the request to a worker that does. The forwarded request hits middleware again and gets rewritten the same way, so the new receiving worker forwards again — looping until undici's headers timeout (~300s) or memory pressure brings the server down (#84504).

How?

  • Runtime: add !actionWasForwarded to the existing forwarding guard in action-handler.ts. The x-action-forwarded header was already set on forwarded requests but wasn't being used to gate the forwarding decision.
  • Tests: Browser-driven e2e at test/e2e/app-dir/action-forward-loop. proxy.ts rewrites POSTs to /with-action (GETs still render normally, so the user can see the page); the page renders a <form> with an inline server action wrapped in a React error boundary. The test clicks the button and waits for the boundary's error UI. Without the fix, the action POST loops on the server and Playwright's 10s waitForSelector times out.

Follow-up TODO left in createForwardedActionResponse: when the forwarded worker returns an action-not-found 404, the forwarder currently swallows it and returns a generic JSON body, so the client throws a generic "unexpected response" error instead of UnrecognizedActionError. Fixing that would let the boundary branch on the specific error class (the boundary has a matching TODO).

Fixes #84504
Closes #84525
Closes #92053

Co-Authored-By: @LiamBoz
Co-Authored-By: @claygeo

  • Bug fix — issue linked with Fixes #
  • Tests added (e2e)
  • Signed / verified commits on the branch

@elishagreenwald

Copy link
Copy Markdown
Contributor Author

@unstubbable it looks like you may have written much of the surrounding action-handler code so I'm hoping this would be something you can review. This is causing a memory leak in prod for us that we had to patch nextjs to avoid. Issue #84504 has been open since October with two stalled prior PRs (#84525 & #92053 ) so I'm really hoping this one is shippable. Thank you kindly!

elishagreenwald and others added 2 commits May 12, 2026 08:34
Avoid infinite peer-forward loops when middleware rewrites paths or the
request was already forwarded (x-action-forwarded). The receiving worker
short-circuits the forwarding decision and falls through to lenient
serverModuleMap resolution instead of forwarding again.

Adds a self-contained e2e regression test that reproduces the loop via a
proxy.ts rewrite: with the fix the request resolves in ~150ms; without
the fix it hangs until the test's 10s abort timer fires.

Fixes vercel#84504
Supersedes vercel#84525, vercel#92053
@unstubbable unstubbable force-pushed the fix/action-peer-forward-84504 branch from e07eda2 to 8738a5d Compare May 12, 2026 10:37
@unstubbable unstubbable changed the title fix: skip peer worker forwarding when action was already forwarded fix: skip worker forwarding when action was already forwarded May 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Failing test suites

Commit: 8738a5d | About building and testing Next.js

pnpm test-deploy test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts (job)

  • action forward loop prevention > does not loop when a rewrite sends the action POST to a route that does not bundle it (DD)
Expand output

● action forward loop prevention › does not loop when a rewrite sends the action POST to a route that does not bundle it

Failed to link project  > NOTE: The Vercel CLI now collects telemetry regarding usage of the CLI.
> This information is used to shape the CLI roadmap and prioritize features.
> You can learn more, including how to opt-out if you'd not like to participate in this program, by visiting the following URL:
> https://vercel.com/docs/cli/about-telemetry
Error: No existing credentials found. Please run `vercel login` or pass "--token"
Learn More: https://err.sh/vercel/no-credentials-found (1)

  343 |
  344 |     if (linkRes.exitCode !== 0) {
> 345 |       throw new Error(
      |             ^
  346 |         `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})`
  347 |       )
  348 |     }

  at NextDeployInstance.setup (lib/next-modes/next-deploy.ts:345:13)
  at lib/e2e-utils/index.ts:285:7
  at Span.traceAsyncFn (../packages/next/src/trace/trace.ts:146:14)
  at createNext (lib/e2e-utils/index.ts:250:12)
  at Object.<anonymous> (lib/e2e-utils/index.ts:340:14)

pnpm test-deploy test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts (job)

  • action forward loop prevention > does not loop when a rewrite sends the action POST to a route that does not bundle it (DD)
Expand output

● action forward loop prevention › does not loop when a rewrite sends the action POST to a route that does not bundle it

Failed to link project  > NOTE: The Vercel CLI now collects telemetry regarding usage of the CLI.
> This information is used to shape the CLI roadmap and prioritize features.
> You can learn more, including how to opt-out if you'd not like to participate in this program, by visiting the following URL:
> https://vercel.com/docs/cli/about-telemetry
Error: No existing credentials found. Please run `vercel login` or pass "--token"
Learn More: https://err.sh/vercel/no-credentials-found (1)

  343 |
  344 |     if (linkRes.exitCode !== 0) {
> 345 |       throw new Error(
      |             ^
  346 |         `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})`
  347 |       )
  348 |     }

  at NextDeployInstance.setup (lib/next-modes/next-deploy.ts:345:13)
  at lib/e2e-utils/index.ts:285:7
  at Span.traceAsyncFn (../packages/next/src/trace/trace.ts:146:14)
  at createNext (lib/e2e-utils/index.ts:250:12)
  at Object.<anonymous> (lib/e2e-utils/index.ts:340:14)

pnpm test-dev-turbo test/development/app-dir/hmr-deleted-page/hmr-deleted-page.test.ts (turbopack) (job)

  • hmr-deleted-page > should not show errors for a deleted page (DD)
Expand output

● hmr-deleted-page › should not show errors for a deleted page

Expected no visible Redbox but found one
header: Runtime Error
./app/page/page.tsx:1:1
Module not found: Can't resolve './style.css'
> 1 | import './style.css'
    | ^^^^^^^^^^^^^^^^^^^^
  2 |
  3 | import { Test } from './test'
  4 |



https://nextjs.org/docs/messages/module-not-found
Show More
description: ./app/page/page.tsx:1:1
Module not found: Can't resolve './style.css'
> 1 | import './style.css'
    | ^^^^^^^^^^^^^^^^^^^^
  2 |
  3 | import { Test } from './test'
  4 |



https://nextjs.org/docs/messages/module-not-found
source: null

  25 |     await waitForHydration(browser)
  26 |
> 27 |     await waitForNoRedbox(browser)
     |     ^
  28 |     expect(await browser.elementByCss('h1').text()).toBe('404')
  29 |   })
  30 | })

  at Object.<anonymous> (development/app-dir/hmr-deleted-page/hmr-deleted-page.test.ts:27:5)

pnpm test-start-turbo test/e2e/app-dir/optimistic-routing/optimistic-routing.test.ts (turbopack) (job)

  • optimistic-routing > rewrite detection (search params): does not use cached pattern when search params cause different rewrite (DD)
Expand output

● optimistic-routing › rewrite detection (search params): does not use cached pattern when search params cause different rewrite

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (e2e/app-dir/optimistic-routing/optimistic-routing.test.ts:396:3)
  at Object.describe (e2e/app-dir/optimistic-routing/optimistic-routing.test.ts:36:1)

unstubbable added a commit that referenced this pull request May 14, 2026
Reopens #93710 from a branch on this repo so the required deploy test
matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and
those deploy jobs are required checks. All commits and credit carry
forward from the original PR by @elishagreenwald, which built on earlier
attempts by @LiamBoz in #84525 and @claygeo in #92053.

When middleware rewrites a Server Action POST to a page that doesn't
bundle the action, the receiving worker forwards the request to a worker
that does. The forwarded request hits middleware again and gets
rewritten the same way, so the new receiving worker forwards again —
looping until undici's headers timeout (~300s) or memory pressure brings
the server down (#84504). The fix is two related changes on the
forwarding code path. The first is an `!actionWasForwarded` guard in
`action-handler.ts` so a request carrying `x-action-forwarded: 1` is
never forwarded a second time. The second is a new branch in
`createForwardedActionResponse` that copies the
`x-nextjs-action-not-found` header and 404 status onto the originating
response when the forwarded worker can't find the action either; without
it the client would see a generic "unexpected response" error instead of
the `UnrecognizedActionError` that the rest of the framework expects.

The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the
scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still
render the page normally; the page renders a `<form>` with an inline
server action wrapped in a React error boundary that branches on
`unstable_isUnrecognizedActionError`. The test clicks the button and
waits for `#action-not-found-error`. Without the loop guard the test
times out at 10s, and without the pass-through the boundary catches a
generic error and the assertion fails — so both changes are individually
load-bearing.

This is a short-term fix. The mid-term direction is to remove
server-side action forwarding altogether and have the client dispatch
each Server Action to the route that actually bundles it, which makes
the entire forwarding code path (and this bug surface) unnecessary.
#90549 is the draft exploring that approach.

Fixes #84504

Closes #93710
Closes #84525
Closes #92053

---------

Co-Authored-By: Elisha Greenwald <ejgreenwald@gmail.com>
Co-Authored-By: LiamBoz <thisamazingnow@gmail.com>
Co-Authored-By: kmaclip <kmartclips@proton.me>
unstubbable added a commit that referenced this pull request May 18, 2026
Reopens #93710 from a branch on this repo so the required deploy test
matrix can run — GitHub Actions doesn't expose secrets to fork PRs, and
those deploy jobs are required checks. All commits and credit carry
forward from the original PR by @elishagreenwald, which built on earlier
attempts by @LiamBoz in #84525 and @claygeo in #92053.

When middleware rewrites a Server Action POST to a page that doesn't
bundle the action, the receiving worker forwards the request to a worker
that does. The forwarded request hits middleware again and gets
rewritten the same way, so the new receiving worker forwards again —
looping until undici's headers timeout (~300s) or memory pressure brings
the server down (#84504). The fix is two related changes on the
forwarding code path. The first is an `!actionWasForwarded` guard in
`action-handler.ts` so a request carrying `x-action-forwarded: 1` is
never forwarded a second time. The second is a new branch in
`createForwardedActionResponse` that copies the
`x-nextjs-action-not-found` header and 404 status onto the originating
response when the forwarded worker can't find the action either; without
it the client would see a generic "unexpected response" error instead of
the `UnrecognizedActionError` that the rest of the framework expects.

The e2e test at `test/e2e/app-dir/action-forward-loop` reproduces the
scenario. A `proxy.ts` rewrites POSTs to `/with-action` so GETs still
render the page normally; the page renders a `<form>` with an inline
server action wrapped in a React error boundary that branches on
`unstable_isUnrecognizedActionError`. The test clicks the button and
waits for `#action-not-found-error`. Without the loop guard the test
times out at 10s, and without the pass-through the boundary catches a
generic error and the assertion fails — so both changes are individually
load-bearing.

This is a short-term fix. The mid-term direction is to remove
server-side action forwarding altogether and have the client dispatch
each Server Action to the route that actually bundles it, which makes
the entire forwarding code path (and this bug surface) unnecessary.
#90549 is the draft exploring that approach.

Fixes #84504

Closes #93710
Closes #84525
Closes #92053

---------

Co-Authored-By: Elisha Greenwald <ejgreenwald@gmail.com>
Co-Authored-By: LiamBoz <thisamazingnow@gmail.com>
Co-Authored-By: kmaclip <kmartclips@proton.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Forwarded server actions with middleware rewrites cause an infinite request loop

2 participants