fix(pages-router): stream piped API responses with backpressure - #2735
Open
NathanDrake2406 wants to merge 1 commit into
Open
fix(pages-router): stream piped API responses with backpressure#2735NathanDrake2406 wants to merge 1 commit into
NathanDrake2406 wants to merge 1 commit into
Conversation
A handler that pipes into `res` (proxying an upstream, echoing a request body) could queue the entire stream in memory. The response bridge acknowledged every write immediately, so `write()` never returned false and a piped source was never paused, regardless of how slowly the client consumed the body. The Node production server also read API responses with `arrayBuffer()`, which held the full body in memory and deferred delivery until the source closed, so long-lived streams (e.g. proxied SSE) never flushed. Hold the write callback while the response body's queue is full and release it from the stream's pull hook. A held callback makes `write()` return false, which pauses any piped source until the consumer reads. Mark responses that are still being written when the handler settles and send them through the streaming path in the Node production server. Complete bodies (`res.json`, `res.send`, `res.end(data)`) keep the buffered path and its Content-Length behaviour.
commit: |
Contributor
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
PagesResponseStreamowns backpressure;handlePagesApiRouteowns the stream-vs-complete decision; adapters only consume itres.json/res.send/res.end(data)behaviour is unchangedWhy
Node streams propagate backpressure by withholding the
_writecallback: a held callback makeswrite()returnfalse, which pauses anypipe()source.PagesResponseStream._writeenqueued each chunk into the WebReadableStreamand invoked the callback synchronously, so the Writable looked infinitely fast. A fast source piped intores(the proxy pattern #1902 enabled) kept producing at full rate while a slow client consumed nothing, and every unread chunk accumulated in the stream's unbounded queue. On real Next.js this cannot happen becauseresis a genuineServerResponsewith socket backpressure; the bridge silently dropped that property.Independently, the Node production server read API responses with
arrayBuffer()before sending. That predates streaming API bodies and was harmless while every API response was complete at resolution time. Once a response can resolve mid-stream, buffering holds the whole body in memory and defers the first byte until the source closes, so a proxied SSE stream never flushes.PagesResponseStreamdesiredSize <= 0and released from the stream'spull()hook (or on destroy)handlePagesApiRoutesendWebResponsestreaming path, with the sameapplication/octet-streamcontent-type fallback the buffered path appliedWhat changed
upstream.pipe(res)with a slow clientarrayBuffer(); first byte only after the source closedsendWebResponse; bytes flush as they are writtenres.json/res.send/res.end(data)mergeHeadersrewrap (same propagation as the streamed-HTML marker)Maintainer review path
packages/vinext/src/server/pages-node-compat.tsfor the backpressure mechanism (_writeparking,pull()release,_destroyrelease) and the marker helpers.packages/vinext/src/server/pages-api-route.tsfor the stream-vs-complete decision after the response settles.packages/vinext/src/server/prod-server.tsfor the stream-vs-buffer branch and content-type fallback.packages/vinext/src/server/pages-request-pipeline.tsandpackages/vinext/src/shims/unified-request-context.tsfor marker propagation across Response rewraps.tests/pages-api-route.test.tsfor the regression proof.Validation
res.jsonis not.tests/pages-api-route.test.tsandtests/pages-bodyparser-config.test.tspass, including the existing fix(pages-router): support stream proxying in API routes #1902 streaming, cancellation, and destroy-mid-stream cases.tests/pages-node-compat.test.ts,tests/pages-request-pipeline.test.ts,tests/unified-request-context.test.tspass (118 tests).vp checkclean on all touched files; pre-commit full check, staged tests, and knip passed.Risk / compatibility
res.write()implies chunked transfer.References