Skip to content

fix(pages-router): stream piped API responses with backpressure - #2735

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/pages-api-stream-backpressure
Open

fix(pages-router): stream piped API responses with backpressure#2735
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/pages-api-stream-backpressure

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Make piped Pages API responses (stream proxying, #1902) deliver incrementally with bounded memory
Core change The response bridge holds write callbacks while the body's queue is full, and the Node production server streams live API bodies instead of buffering them
Key boundary PagesResponseStream owns backpressure; handlePagesApiRoute owns the stream-vs-complete decision; adapters only consume it
Expected impact A piped source pauses when the client reads slowly; proxied SSE/long-lived streams flush incrementally on Node prod. res.json/res.send/res.end(data) behaviour is unchanged

Why

Node streams propagate backpressure by withholding the _write callback: a held callback makes write() return false, which pauses any pipe() source. PagesResponseStream._write enqueued each chunk into the Web ReadableStream and invoked the callback synchronously, so the Writable looked infinitely fast. A fast source piped into res (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 because res is a genuine ServerResponse with 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.

Area Principle / invariant What this PR changes
PagesResponseStream A Writable must not acknowledge writes faster than its consumer drains Write callback is parked while desiredSize <= 0 and released from the stream's pull() hook (or on destroy)
handlePagesApiRoute Only the bridge layer can know whether a body is complete or live Responses still being written when the handler settles are marked streamed
Node prod server A live stream must be forwarded, not materialised Marked responses take the existing sendWebResponse streaming path, with the same application/octet-stream content-type fallback the buffered path applied

What changed

Scenario Before After
upstream.pipe(res) with a slow client Source drained at full rate; unread chunks queued without bound Source pauses after roughly one queued chunk plus the Writable's 16 KB buffer, resumes per consumer read
Piped/streaming API response on Node prod Fully buffered via arrayBuffer(); first byte only after the source closed Streams through sendWebResponse; bytes flush as they are written
res.json / res.send / res.end(data) Buffered send with Content-Length Unchanged (complete bodies are not marked streamed and keep the buffered path)
Client cancels mid-stream Writable destroyed Unchanged, and a parked write callback is released so piped sources unwind
Middleware headers merged onto an API response Marker n/a Streamed marker survives the mergeHeaders rewrap (same propagation as the streamed-HTML marker)
Maintainer review path
  1. packages/vinext/src/server/pages-node-compat.ts for the backpressure mechanism (_write parking, pull() release, _destroy release) and the marker helpers.
  2. packages/vinext/src/server/pages-api-route.ts for the stream-vs-complete decision after the response settles.
  3. packages/vinext/src/server/prod-server.ts for the stream-vs-buffer branch and content-type fallback.
  4. packages/vinext/src/server/pages-request-pipeline.ts and packages/vinext/src/shims/unified-request-context.ts for marker propagation across Response rewraps.
  5. tests/pages-api-route.test.ts for the regression proof.
Validation
  • New regression test: a 20-chunk (64 KB each) piped source must not drain while nothing reads the body, and completes fully once the body is consumed.
  • New contract test: a response settled mid-pipe is marked streamed; res.json is not.
  • All 70 tests in tests/pages-api-route.test.ts and tests/pages-bodyparser-config.test.ts pass, 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.ts pass (118 tests).
  • vp check clean on all touched files; pre-commit full check, staged tests, and knip passed.
Risk / compatibility
  • No public API change; the marker is a non-enumerable-style internal property consumed only by vinext adapters.
  • Complete-body responses keep the buffered path, so Content-Length emission on Node prod (fix(metadata): preserve Content-Length for fully buffered responses #2703 behaviour) is unchanged.
  • Responses that stream then finish quickly now go chunked on Node prod instead of buffered with Content-Length. This matches Next.js, where res.write() implies chunked transfer.
  • A handler that writes and never ends nor pipes still relies on the existing auto-end/cancellation paths; parked callbacks are always released on destroy, so no new hang path is introduced.

References

Reference Why it matters
#1902 Introduced stream proxying for Pages API routes; this PR makes that pattern flow-controlled and actually incremental on Node prod
#2703 Content-Length preservation for buffered responses, deliberately kept intact for complete bodies

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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2735
npm i https://pkg.pr.new/create-vinext-app@2735
npm i https://pkg.pr.new/@vinext/types@2735
npm i https://pkg.pr.new/vinext@2735

commit: 839ea7e

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 839ea7e against base c9a4a84 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ -0.0%
Dev server cold start vinext 2.83 s 2.81 s ⚫ -0.7%
Production build time vinext 2.96 s 2.96 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

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