Skip to content

Fix server action forwarding loop with middleware rewrites#93792

Merged
unstubbable merged 4 commits into
canaryfrom
hl/fix-action-forwarding-loop
May 14, 2026
Merged

Fix server action forwarding loop with middleware rewrites#93792
unstubbable merged 4 commits into
canaryfrom
hl/fix-action-forwarding-loop

Conversation

@unstubbable

Copy link
Copy Markdown
Contributor

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

elishagreenwald and others added 4 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 #84504
Supersedes #84525, #92053
@elishagreenwald

Copy link
Copy Markdown
Contributor

Thanks so much for picking this up unstubbable! Really appreciate it! 🙏

@github-actions

github-actions Bot commented May 12, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: fe62eb3

@unstubbable unstubbable marked this pull request as ready for review May 12, 2026 13:29
@unstubbable unstubbable requested a review from lubieowoce May 12, 2026 13:29
@unstubbable unstubbable enabled auto-merge (squash) May 12, 2026 13:30
@elishagreenwald

Copy link
Copy Markdown
Contributor

@lubieowoce would you be able to review this please? I'm sorry to bother you but this is causing issues for us in prod and we had to patch next to fix it. Hoping we can get this merged so we have a clear upgrade path. If you're planning to review later that's fine too and please ignore me :) Either way thank you all kindly and keep up all your great work! 🙏

@unstubbable unstubbable merged commit 20892dd into canary May 14, 2026
294 of 299 checks passed
@unstubbable unstubbable deleted the hl/fix-action-forwarding-loop branch May 14, 2026 15:01
@elishagreenwald

Copy link
Copy Markdown
Contributor

@unstubbable I know I'm pushing it but any chance this could land in a v16.2.x patch release? We're carrying a bun patch in prod against next@16.1.1 today; a stable backport would let us upgrade and drop the patch without committing to the full v16.3.0 cut yet. Totally fine if not. 🙈

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>
unstubbable added a commit that referenced this pull request May 19, 2026
…#93919)

Backports:

- #93792

Co-authored-by: Elisha Greenwald <ejgreenwald@gmail.com>
Co-authored-by: LiamBoz <thisamazingnow@gmail.com>
Co-authored-by: kmaclip <kmartclips@proton.me>
@unstubbable

Copy link
Copy Markdown
Contributor Author

@elishagreenwald We'll include the fix in the next patch release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Forwarded server actions with middleware rewrites cause an infinite request loop

3 participants