Skip to content

fix: Pages Router SSR streaming#514

Open
JaredStowell wants to merge 9 commits intocloudflare:mainfrom
JaredStowell:jstowell/pages-prod-ssr-streaming
Open

fix: Pages Router SSR streaming#514
JaredStowell wants to merge 9 commits intocloudflare:mainfrom
JaredStowell:jstowell/pages-prod-ssr-streaming

Conversation

@JaredStowell
Copy link
Contributor

@JaredStowell JaredStowell commented Mar 13, 2026

Fix the Pages Router Node production SSR path so responses stay streamed instead of being fully buffered before send.

Previously, the Pages SSR path in prod-server.ts drained the rendered Response with response.arrayBuffer() before writing it to the socket. That removed progressive streaming, copied the full HTML payload, and increased TTFB and memory usage on large responses.

This change preserves the original Response.body stream through the existing streamed Node response path and fixes the related response-merge edge cases that showed up once streaming was preserved.

Changes

  • switch the Pages Router SSR production path over to the streamed Node response sender instead of buffering the full body first
  • preserve streamed bodies while still applying middleware status/header overrides
  • handle no-body rewrite statuses (204, 205, 304) correctly when middleware rewrites a streamed Pages SSR response
  • strip body/framing headers for no-body responses
  • strip stale Content-Length from streamed Pages SSR responses, including values inherited from:
    • getServerSideProps
    • middleware rewrites
  • add production regression coverage for:
    • incremental Pages SSR streaming
    • middleware header preservation on streamed responses
    • no-body rewrite statuses on streamed Pages SSR
    • stale Content-Length from getServerSideProps
    • stale Content-Length from middleware rewrites
  • add Pages Router fixture coverage for delayed streaming and bad content-length cases

Why

Pages Router SSR already produces a streamed Response, but the Node prod server was materializing that stream into a full buffer before sending it.

This PR preserves streaming behavior and also handles the edge cases with the streamed merge path:

  • bodyless middleware rewrite statuses like 204, 205, and 304
  • stale Content-Length on streamed SSR responses

Testing

  • pnpm test tests/pages-router.test.ts
  • pnpm test tests/features.test.ts -t "mergeWebResponse"
  • pnpm run fmt
  • pnpm run typecheck

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 13, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@514

commit: f0dd479

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 914e4830b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@JaredStowell JaredStowell marked this pull request as draft March 13, 2026 04:38
@JaredStowell JaredStowell marked this pull request as ready for review March 13, 2026 05:38
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4565b5863c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@JaredStowell JaredStowell marked this pull request as draft March 13, 2026 06:05
@JaredStowell JaredStowell marked this pull request as ready for review March 17, 2026 04:59
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46e7de5dfd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1288 to +1289
if (shouldStreamPagesResponse || !mergedResponse.body) {
await sendWebResponse(mergedResponse, req, res, compress);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel page render stream on HEAD in streamed Pages path

Routing tagged Pages responses through sendWebResponse here means HEAD requests now take the streamed path, but sendWebResponse exits on HEAD without consuming or canceling webResponse.body (prod-server.ts sendWebResponse HEAD branch). Because the Pages renderer builds a live ReadableStream, those requests can keep server-side rendering work running with no downstream reader, wasting CPU/memory under HEAD health checks or crawlers. Please cancel the body stream (or avoid the streamed path for HEAD) before returning.

Useful? React with 👍 / 👎.

@james-elicx
Copy link
Collaborator

/bigbonk review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Pages Router SSR Streaming

The core change is sound -- preserving the Response.body stream through sendWebResponse instead of buffering via arrayBuffer() is the right fix for TTFB and memory. The edge-case handling for no-body statuses and stale Content-Length is thorough, and the test coverage is excellent.

I have two issues worth addressing (one correctness, one maintainability) and a few minor observations.

Issue 1: HEAD requests leak the SSR render stream (correctness)

Now that streamed Pages responses route through sendWebResponse (line 1289), HEAD requests will enter the streaming path. sendWebResponse handles HEAD by calling res.end() at line 599 without consuming or canceling webResponse.body. For static or short-lived bodies this is mostly harmless (GC cleans up), but for the Pages SSR render stream this means React will continue server-rendering the full component tree with no downstream reader. On crawlers or health checks that send HEAD, this wastes CPU and memory.

The Codex review flagged this too. Suggested fix: cancel the body stream when sendWebResponse exits for HEAD (add webResponse.body.cancel().catch(() => {}) before the return in the HEAD branch around line 599). You already have cancelResponseBody() defined -- reuse it.

Issue 2: Three near-identical merge implementations (maintainability)

The no-body/streamed-length logic is now triplicated across:

  • prod-server.ts -> mergeWebResponse
  • worker-utils.ts -> mergeHeaders
  • deploy.ts -> mergeHeaders (template string)

These must stay perfectly in sync or one deployment target silently diverges. I understand the constraint (deploy.ts is a code-gen template, worker-utils.ts runs in Workers), but this is getting complex enough that a follow-up to factor out a shared implementation or at least add a prominent sync-warning comment would reduce future risk.

Not a blocker for this PR, but worth tracking.

const mergedResponse = mergeWebResponse(middlewareHeaders, response, middlewareRewriteStatus);

if (shouldStreamPagesResponse || !mergedResponse.body) {
await sendWebResponse(mergedResponse, req, res, compress);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HEAD requests will leak the SSR render stream. With this change, streamed Pages responses now always route through sendWebResponse, which on HEAD calls res.end() without canceling webResponse.body. Since the Pages renderer produces a live ReadableStream backed by React's render pipeline, the rendering work continues with no downstream reader.

This is a pre-existing gap in sendWebResponse, but this PR significantly increases its impact by routing streamed Pages SSR responses through this path. Consider adding cancelResponseBody(webResponse) in the HEAD branch of sendWebResponse (around line 599).

statusOverride?: number,
): Response {
const status = statusOverride ?? response.status;
const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: mergeResponseHeaders is called unconditionally here, even for the early-return path (lines 223-230). Since it iterates all response headers and copies them into a new record, this is wasted work when the function returns the original response unchanged. The early-return optimization is weaker than it appears because the checks for shouldDropBody and shouldStripStreamLength depend on mergedHeaders. Not a big deal for correctness, just worth noting.

if (!Object.keys(extraHeaders).length && !statusOverride) return response;
const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]);
function isVinextStreamedHtmlResponse(response: Response): boolean {
return response.__vinextStreamedHtmlResponse === true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: In the deploy.ts template, response.__vinextStreamedHtmlResponse is accessed without a type cast (unlike worker-utils.ts and prod-server.ts which use (response as ResponseWithVinextStreamingMetadata).__vinextStreamedHtmlResponse). This works because the template is emitted as plain JS, but the inconsistency is slightly surprising when reading the three implementations side-by-side. A comment noting this is intentional (template JS, no TS narrowing needed) would help future readers.

});
// Mark the normal streamed HTML render so the Node prod server can strip
// stale Content-Length only for this path, not for custom gSSP responses.
streamedPageResponse.__vinextStreamedHtmlResponse = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an ad-hoc expando property on the Response object to pass metadata across module boundaries works but is fragile -- it will be silently lost if anything clones the response (e.g., response.clone(), or constructing new Response(response.body, ...)). This is fine for the current code paths (and mergeWebResponse in prod-server.ts correctly checks the flag before creating a new Response), but it's worth documenting this invariant somewhere so future code doesn't accidentally drop the flag.

const finalStatus = middlewareRewriteStatus ?? response.status;
const finalStatusText =
finalStatus === response.status ? response.statusText || undefined : undefined;
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shouldStreamPagesResponse flag is captured from the original response before mergeWebResponse creates a new one. This is correct (since mergeWebResponse constructs new Response(...) which drops the expando), but subtle. A brief comment explaining the ordering would help:

Suggested change
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);
// Check the original response before merging -- mergeWebResponse creates a new Response
// which drops the __vinextStreamedHtmlResponse expando property.
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);

const buf = typeof body === "string" ? Buffer.from(body) : body;
const baseType = contentType.split(";")[0].trim();
const encoding = compress ? negotiateEncoding(req) : null;
const {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good change -- stripping both content-type and content-length from extraHeaders before sendCompressed applies its own is correct. Previously only content-length was stripped in the uncompressed branch, so a stale Content-Type from middleware/gSSP could leak through the spread in the compressed branch.

});

it("streams Pages SSR responses incrementally in production", async () => {
// Parity target: Next.js streams Node responses via sendResponse() ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid test -- verifying the snapshot at a time delay to confirm incremental streaming behavior is a nice approach. The 600ms setTimeout in the fixture + 120ms snapshot delay gives a good window.

One concern: in CI under load, the 120ms snapshot delay could fire after the full response arrives if the 600ms timer resolves faster than expected (timers aren't precise). If this test becomes flaky, consider increasing the fixture's delay or asserting on the snapshot vs final body difference rather than exact content presence/absence.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 17, 2026

Review posted successfully. Here's a summary of the feedback:

Key findings:

  1. HEAD request body leak (correctness) -- The main actionable issue. Now that streamed Pages SSR responses route through sendWebResponse, HEAD requests will trigger React's render pipeline with no downstream reader. The sendWebResponse function exits for HEAD without canceling the body stream. The cancelResponseBody() helper already exists in this PR and should be reused in the HEAD branch.

  2. Triple merge implementation (maintainability) -- The no-body status / streamed content-length logic is now replicated across prod-server.ts, worker-utils.ts, and deploy.ts (template string). These must stay in sync or one deployment target silently diverges. Not a blocker, but worth tracking for a follow-up.

  3. Minor suggestions -- Added comments about the __vinextStreamedHtmlResponse expando property fragility, the ordering subtlety of checking it before mergeWebResponse, and the early-return optimization being weaker than it looks due to unconditional mergeResponseHeaders call.

  4. Positive notes -- The sendCompressed content-type stripping fix is a good catch, and the streaming test approach using snapshot delays is solid (though may need tuning if CI timers prove unreliable).

github run

@james-elicx
Copy link
Collaborator

/bigbonk re-review

Copy link
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Pages Router SSR Streaming

The core change is well-motivated and the implementation is thorough. Preserving the Response.body stream through sendWebResponse instead of buffering via arrayBuffer() is the right fix for TTFB and memory on streamed Pages SSR. The edge-case handling for no-body statuses and stale Content-Length is comprehensive, and the test coverage is excellent.

I found one correctness issue, one subtle sync divergence between the three merge implementations, and a few minor observations.

Issue 1: mergeWebResponse doesn't filter middleware content-length from the merge (correctness / sync divergence)

The worker-utils.ts and deploy.ts merge implementations both filter out middleware content-length unconditionally during the initial header merge loop (if (isContentLengthHeader(k)) continue). But prod-server.ts's mergeWebResponse delegates to mergeResponseHeaders, which spreads all middleware headers (including content-length) into the merged record with no filtering.

In practice this is currently safe because:

  • Streamed HTML responses hit shouldStripStreamLength which removes it
  • Non-streamed SSR responses go through sendCompressed which now strips content-length from extraHeaders
  • No-body statuses strip it explicitly

But it's a latent inconsistency: if any future code path reads the merged headers from mergeWebResponse without going through sendCompressed or sendWebResponse, a middleware content-length will leak through. The other two implementations prevent this at the source.

The early-return check also differs: prod-server.ts uses !Object.keys(middlewareHeaders).length while the others use !Object.keys(extraHeaders).some(key => !isContentLengthHeader(key)). If middleware only sets content-length, prod-server.ts skips the early return and creates a new Response unnecessarily.

Not a blocker since the downstream consumers currently handle it, but worth aligning for defensive correctness.

Issue 2: sendWebResponse HEAD path writes headers before canceling the body (ordering nit)

At line 598-604, writeHead(nodeHeaders) is called, then the HEAD check cancels the body and calls res.end(). The cancelResponseBody addition is good (the previous review flagged the leak and this PR fixes it), but writeHead runs unconditionally, including the compression negotiation logic at lines 580-596 which deletes content-length from headers and adds Content-Encoding/Vary for compressible types.

For HEAD requests, this means the client receives Content-Encoding: br and Vary: Accept-Encoding headers even though no body (and no compression) is applied. This is technically correct per HTTP (HEAD should return headers as if it were a GET), but it's worth noting that the compression-related header mutation runs even when the body is never sent. Minor -- just an observation, not a change request.

Minor observations

See inline comments.

statusOverride?: number,
): Response {
const status = statusOverride ?? response.status;
const mergedHeaders = mergeResponseHeaders(middlewareHeaders, response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mergeResponseHeaders at line 140 does { ...middlewareHeaders } which copies ALL middleware headers (including content-length) into the merged record. In contrast, worker-utils.ts and deploy.ts both skip middleware content-length during the merge loop:

if (isContentLengthHeader(k)) continue;

This means mergeWebResponse can produce a merged record that still contains a stale middleware content-length for non-streamed, non-no-body responses. Currently mitigated by sendCompressed stripping it downstream, but it's a latent inconsistency with the other two implementations.

Consider either:

  1. Adding content-length filtering to mergeResponseHeaders (or a wrapper around it)
  2. Stripping middleware content-length from middlewareHeaders before passing it to mergeResponseHeaders

This would also let the early-return check at line 227 align with the !Object.keys(extraHeaders).some(key => !isContentLengthHeader(key)) pattern used in the other two implementations.


// HEAD requests: send headers only, skip the body
if (req.method === "HEAD") {
cancelResponseBody(webResponse);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix -- this addresses the HEAD request body leak that the previous review flagged. The SSR render stream is now properly canceled instead of left running with no downstream reader.

One subtlety: writeHead at line 598 has already been called at this point, including the compression negotiation that may have mutated nodeHeaders (deleted content-length, added Content-Encoding). For HEAD requests this is technically fine (HEAD should mirror GET headers), but it means the content-length that the client sees for HEAD is whatever was in the response headers minus the compression stripping. If a future caller relies on HEAD returning an accurate Content-Length for range-request preflight, this could be surprising.

const finalStatus = middlewareRewriteStatus ?? response.status;
const finalStatusText =
finalStatus === response.status ? response.statusText || undefined : undefined;
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering here is critical and correct -- isVinextStreamedHtmlResponse must check the original response before mergeWebResponse creates new Response(...) which drops the expando. A short comment documenting this invariant would help future readers:

Suggested change
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);
// Capture the streaming flag before merging -- mergeWebResponse creates a new Response
// which drops the __vinextStreamedHtmlResponse expando property.
const shouldStreamPagesResponse = isVinextStreamedHtmlResponse(response);

Comment on lines +278 to +284
const {
"content-length": _cl,
"Content-Length": _CL,
"content-type": _ct,
"Content-Type": _CT,
...headersWithoutBodyHeaders
} = extraHeaders;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good change -- stripping both content-type and content-length from extraHeaders before sendCompressed applies its own values is correct. Previously only content-length was stripped in the uncompressed branch, so a stale Content-Type from middleware or gSSP could leak through in the compressed branch.

Note that this uses exact-case destructuring ("content-length" and "Content-Length"). Since mergeResponseHeaders uses Headers.forEach() which yields lowercase keys, the lowercase variants will catch the response's own headers. But if middleware headers are in some other casing (e.g., "Content-length"), neither destructuring key would match. In practice Next.js middleware uses Headers which normalizes to lowercase, so this should be fine.

});
// Mark the normal streamed HTML render so the Node prod server can strip
// stale Content-Length only for this path, not for custom gSSP responses.
streamedPageResponse.__vinextStreamedHtmlResponse = true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using an ad-hoc expando property on the Response object works but is inherently fragile -- it will be silently dropped by response.clone(), new Response(response.body, ...), or anything that reconstructs the response. The code currently handles this correctly (checking the flag before merging), but it's worth documenting this invariant.

Consider adding a comment here like:

// WARNING: This expando property is lost if the Response is cloned or reconstructed.
// All consumers must check it before creating new Response objects from this one.

Alternatively, a custom response header (e.g., x-vinext-streamed: 1) would survive cloning and reconstruction, though it adds a header to the wire response that would need to be stripped before sending.

if (!Object.keys(extraHeaders).length && !statusOverride) return response;
const NO_BODY_RESPONSE_STATUSES = new Set([204, 205, 304]);
function isVinextStreamedHtmlResponse(response: Response): boolean {
return response.__vinextStreamedHtmlResponse === true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: In the deploy.ts template, response.__vinextStreamedHtmlResponse is accessed without a type cast (unlike worker-utils.ts and prod-server.ts which use as ResponseWithVinextStreamingMetadata). This is intentional since the template is emitted as plain JS, but a brief comment noting this would help readers who are cross-referencing the three implementations.

Comment on lines +2498 to +2501
});

it("streams Pages SSR responses incrementally in production", async () => {
// Parity target: Next.js streams Node responses via sendResponse() ->
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streaming snapshot test is a great approach. One concern for CI reliability: the fixture uses a 600ms setTimeout and the snapshot is captured at 120ms after the first chunk. Under CI load, timer precision can slip -- if the 600ms timer fires early, the snapshot might include the delayed content, causing a false failure on the not.toContain assertion.

If this becomes flaky, consider either:

  • Increasing the fixture delay to 2-3 seconds
  • Asserting on snapshot.length < body.length (size-based) rather than exact content presence/absence

Not a blocker -- just flagging for future reference.

const status = statusOverride ?? response.status;
const merged = new Headers();
for (const [k, v] of Object.entries(extraHeaders)) {
if (isContentLengthHeader(k)) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unconditionally drops middleware content-length, which is correct for the merge step. But it means that even if middleware intentionally sets content-length for a non-streamed response (e.g., a custom binary middleware response), it gets stripped. In practice this is fine because the response's own Content-Length (from the Response constructor) takes precedence via merged.set(k, v) in the response header loop below. Just noting the semantic: middleware can never influence Content-Length, which matches Next.js behavior.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 18, 2026

Review submitted successfully. Here's a summary of what I found:

Key findings:

  1. Middleware content-length handling divergence between the three merge implementations -- worker-utils.ts and deploy.ts unconditionally filter out middleware content-length during the merge loop, but prod-server.ts's mergeWebResponse delegates to mergeResponseHeaders which doesn't filter it. Currently safe because downstream consumers (sendCompressed, sendWebResponse) handle it, but it's a latent inconsistency that could bite if a new code path is added.

  2. HEAD request body cancellation -- The PR correctly fixes the body stream leak on HEAD requests (adding cancelResponseBody at line 602), which was flagged by prior reviews. Good fix.

  3. Streamed response flag ordering -- The code correctly captures isVinextStreamedHtmlResponse before mergeWebResponse creates a new Response (which drops the expando). Suggested a comment documenting this invariant.

  4. sendCompressed content-type stripping -- Good fix stripping both content-type and content-length from extraHeaders before applying its own, preventing stale values from leaking through.

  5. Test coverage -- Excellent. The streaming snapshot test approach, no-body status tests, and gSSP content-length tests are thorough. Flagged minor CI flakiness risk on the 600ms/120ms timer-based snapshot assertion.

github run

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.

2 participants