Skip to content

fix: terminate streamed responses cleanly when a render fails#3532

Open
biilmann wants to merge 1 commit into
opennextjs:mainfrom
biilmann:fix/streamed-response-termination
Open

fix: terminate streamed responses cleanly when a render fails#3532
biilmann wants to merge 1 commit into
opennextjs:mainfrom
biilmann:fix/streamed-response-termination

Conversation

@biilmann

Copy link
Copy Markdown

Description

A streamed SSR response commits its headers (typically a 200, with no Content-Length) as soon as Next.js flushes them — well before the body finishes. If the render then fails after that point, the response-finalization logic in src/run/handlers/server.ts had no error path, and could emit a response the edge cannot parse as a complete HTTP message (which ATS reports as a 502, ats_status_502_invalid_http_response).

Two concrete failure modes:

  1. Render aborts mid-stream after headers are committed → the body stream is held open by the keepOpenUntilNextFullyRendered TransformStream, but the underlying @fastly/http-compute-js source stream never closes, so the response hangs open indefinitely.
  2. Render throws after headers are committed → the catch set statusCode = 500 and wrote 'Internal Server Error', but the status change is a no-op once headers are sent. The client receives a 200 with the error string concatenated onto partial HTML — a garbled but "complete" response.

Exposure scales with how long the response stream stays open, i.e. pages that do a lot of async work during render.

Fix

  • When the handler fails after headers are committed, end the response cleanly (so buffered bytes flush in order) and record the error, instead of appending 'Internal Server Error'. When headers are not yet sent, the existing 500 path is unchanged.
  • Replace the body TransformStream (flush-only, no error path) with a ReadableStream that:
    • surfaces a recorded render error to the platform (so a failed render terminates the response as a clean stream error / connection abort rather than an unparseable "success"), and
    • if the response was destroyed without a clean end, errors promptly instead of hanging.
  • The happy path is unchanged: full body streams through, then background work runs, then the stream closes.

Documentation

N/A — internal behavior of the server handler; no public API change.

Tests

Added src/run/handlers/server-streaming-termination.test.ts, which mirrors the response-finalization pipeline from server.ts and covers three cases:

  • baseline — a clean render still produces a well-framed 200 with the full body (no regression / no dropped bytes);
  • abort mid-stream after headers — the response now errors promptly instead of hanging open;
  • throw after headers — the stream errors cleanly; 'Internal Server Error' is never concatenated onto the partial HTML.

Note: the test replicates the finalization pipeline rather than importing server.ts directly, because that module's init does top-level await getRunConfig() + Next.js imports. The streaming/termination logic is kept in lock-step with server.ts. Happy to convert this to an e2e fixture test instead if maintainers prefer exercising the shipped handler end-to-end.

You can test this change yourself like so:

  1. npm test -- server-streaming-termination

Relevant links (GitHub issues, etc.) or a picture of cute animal

🐢

A streamed SSR response commits its headers (typically a 200, with no
Content-Length) as soon as Next.js flushes them, well before the body
finishes. If the render then fails *after* that point, response
finalization had no error path:

- a render that aborts the response mid-stream leaves the body hung open
  forever (the @fastly/http-compute-js source stream never closes), and
- a render that throws hits the catch which sets statusCode=500 and writes
  "Internal Server Error" - but the status change is a no-op once headers
  are sent, so the client receives a 200 with the error string concatenated
  onto partial HTML (a garbled but "complete" response).

Both reach the edge as an unparseable HTTP message, which ATS reports as a
502 (ats_status_502_invalid_http_response). Exposure scales with how long
the response stream stays open, i.e. with pages that do a lot of async work
during render.

Fix:
- When the handler fails after headers are committed, end the response
  cleanly (so buffered bytes flush in order) and record the error instead of
  appending "Internal Server Error".
- Replace the body TransformStream (flush-only, no error path) with a
  ReadableStream that surfaces a recorded render error to the platform and,
  if the response was destroyed without a clean end, errors promptly instead
  of hanging. The happy path is unchanged (full body, then background work,
  then close).

Adds regression tests that mirror the finalization pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@biilmann biilmann requested a review from a team as a code owner June 29, 2026 03:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant