Skip to content

feat: Node.js native stream rendering for App Router#89566

Draft
feedthejim wants to merge 47 commits intocanaryfrom
feedthejim/node-stream-rewrite
Draft

feat: Node.js native stream rendering for App Router#89566
feedthejim wants to merge 47 commits intocanaryfrom
feedthejim/node-stream-rewrite

Conversation

@feedthejim
Copy link
Copy Markdown
Contributor

@feedthejim feedthejim commented Feb 6, 2026

Summary

Replaces WHATWG web stream APIs with Node.js native streams for server-side rendering on the Node.js runtime, gated behind experimental.useNodeStreams.

Core approach:

  • Separate pre-compiled runtime bundles (-nodestreams variants) to avoid size regression when the flag is off (same pattern as experimental React channel)
  • Direct Readable-to-ServerResponse piping, no web stream conversion overhead
  • stream-ops.ts: Compile-time conditional exports via process.env.__NEXT_USE_NODE_STREAMS that eliminate branching in app-render.tsx (~35 call sites unified)
  • Edge runtime forced to false (no node:stream on edge), with proper webpack DCE via if/else guards

Benchmark (bench/basic-app, force-dynamic, Turbopack prod)

Metric Web Streams Node Streams Delta
Single Client (c=1)
Throughput 794 req/s 1,160 req/s +46%
Avg Latency 0.72 ms 0.24 ms -67%
Under Load (c=100)
Throughput 1,089 req/s 1,546 req/s +42%
Avg Latency 91.0 ms 64.1 ms -30%
p50 Latency 85.0 ms 61.0 ms -28%
p99 Latency 139 ms 86 ms -38%

Key changes

New files

  • stream-ops.ts: Compile-time conditional exports using process.env.__NEXT_USE_NODE_STREAMS. Provides unified function names (continueFizzStream, continueStaticPrerender, streamToBuffer, chainStreams, createInlinedDataStream, resumeAndAbort, etc.) that resolve to web or node implementations at bundle time via DCE.
  • pipeable-stream-wrappers.ts: Wraps React's renderToPipeableStream / resumeToPipeableStream / Flight pipeable in async/await interface matching the web stream equivalents.
  • node-stream-helpers.ts: Node.js Transform pipeline utilities (buffered transform, head insertion, flight data injection, suffix insertion, etc.)
  • pipe-readable.ts: pipeNodeReadableToResponse() for direct Node Readable to ServerResponse piping.
  • instant-validation/stream-utils.ts: Node stream support for instant validation (debug channels).

Modified files

  • app-render.tsx: Eliminated all useNodeStreams / useNodeStreamsPPR local variables. Remaining ~11 inline process.env.__NEXT_USE_NODE_STREAMS checks are for Fizz render wrapping, stream tee+accumulate patterns, and dynamic render paths where web/node APIs differ too much to abstract.
  • app-render-prerender-utils.ts: Unified methods on ReactServerPrerenderResult (asFlightStream(), consumeAsFlightStream(), asUnclosingFlightStream()) and createReactServerPrerenderResultFromPrerender() factory.
  • render-result.ts + flight-render-result.ts: Accept Node Readable as response type.
  • entry-base.ts: Exports chainNodeStreams for use in the app-page.ts template (avoids relative require in user-bundled code).
  • debug-channel-server.ts: Node-native debug channel implementation instead of bridging web streams.
  • next-runtime.webpack-config.js + taskfile.js: Separate -nodestreams runtime bundle variants (including experimental-nodestreams combos).
  • module.compiled.js: Runtime bundle selection based on __NEXT_USE_NODE_STREAMS env var.
  • config-shared.ts + config-schema.ts: experimental.useNodeStreams flag definition.
  • define-env.ts: Force __NEXT_USE_NODE_STREAMS=false for edge builds.
  • config.ts: __NEXT_USE_NODE_STREAMS env var support for CI testing.

Covered render paths

  • Dynamic rendering (renderToHTMLOrFlightImpl): Flight RSC render, Fizz HTML render, dynamic HTML resume, error fallback
  • Static prerendering (prerenderToStream): RSC prerender, client prerender (PPR + non-PPR + legacy), static/dynamic/fallback output, resume-and-abort
  • Runtime prefetch (selectRSCPrerenderForRuntimePrefetch): RSC prerender with runtime prefetch transform
  • Staged rendering: Flight render + tee + accumulate with StagedRenderingController
  • Validation: validateStagedShell, validateNavigationShell, warmUpInstantValidationClient
  • Instant validation: Node-native debug channel support for segment-level validation streams

CI

  • test-node-streams-dev (Turbopack) and test-node-streams-prod (Webpack) CI jobs
  • Both run with useNodeStreams and cacheComponents enabled
  • Test manifest: test/use-node-streams-tests-manifest.json (includes all app-dir e2e, integration, production, and development tests via glob rules)
  • Benchmark script: bench/basic-app/benchmark.sh

Test plan

  • Build passes with pnpm --filter=next build (both normal and nodestreams bundles)
  • Tree-shaking verified (ReadableStream minimal in nodestreams bundle, PassThrough minimal in normal bundle)
  • bench/basic-app builds and serves with useNodeStreams: true
  • Dynamic rendering works end-to-end
  • Benchmark shows consistent throughput improvement (+42% under load, +46% single client)
  • Request async storage preserved in node flight streams
  • Instant validation support for node streams
  • Edge runtime properly excluded (__NEXT_USE_NODE_STREAMS=false)
  • CI test suite passes with node streams enabled

Extracted Follow-up Stack

@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch from 7f2d09b to 15bb67a Compare February 6, 2026 01:43
@nextjs-bot
Copy link
Copy Markdown
Collaborator

nextjs-bot commented Feb 6, 2026

Tests Passed

@nextjs-bot
Copy link
Copy Markdown
Collaborator

nextjs-bot commented Feb 6, 2026

Stats from current PR

🔴 3 regressions

Metric Canary PR Change Trend
node_modules Size 467 MB 570 MB 🔴 +104 MB (+22%) ▁▁▁▁▁
Webpack Build Time 13.889s 14.731s 🔴 +842ms (+6%) ▂▃▅▁▁
Webpack Build Time (cached) 14.022s 14.889s 🔴 +867ms (+6%) ▂▃▅▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 455ms 456ms ▁▁▁▁▁
Cold (Ready in log) 438ms 439ms ▆▅▂▆▅
Cold (First Request) 1.126s 1.193s ██▁██
Warm (Listen) 457ms 456ms ▁▁▁▁▁
Warm (Ready in log) 442ms 444ms ▁▁▂▁▁
Warm (First Request) 335ms 336ms ▁▅▅▅▅
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 457ms 456ms ▁▁▅▁▁
Cold (Ready in log) 438ms 437ms ▃▄▅▃▄
Cold (First Request) 1.860s 1.864s ▂▃▆▁▂
Warm (Listen) 456ms 456ms ▁▁▅▁▁
Warm (Ready in log) 437ms 440ms ▃▃▅▃▃
Warm (First Request) 1.884s 1.883s ▂▃▅▂▂

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 3.785s 3.854s ▁▁▃▁▁
Cached Build 3.816s 3.823s ▁▁▃▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 13.889s 14.731s 🔴 +842ms (+6%) ▂▃▅▁▁
Cached Build 14.022s 14.889s 🔴 +867ms (+6%) ▂▃▅▁▁
node_modules Size 467 MB 570 MB 🔴 +104 MB (+22%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **437 kB** → **437 kB** ✅ -7 B

81 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 758 B 756 B
Total 758 B 756 B ✅ -2 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 451 B 449 B
Total 451 B 449 B ✅ -2 B

📦 Webpack

Client

Main Bundles
Canary PR Change
5528-HASH.js gzip 5.47 kB N/A -
6280-HASH.js gzip 57 kB N/A -
6335.HASH.js gzip 169 B N/A -
912-HASH.js gzip 4.53 kB N/A -
e8aec2e4-HASH.js gzip 62.5 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 255 B 254 B
main-HASH.js gzip 39.1 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
262-HASH.js gzip N/A 4.53 kB -
2889.HASH.js gzip N/A 169 B -
5602-HASH.js gzip N/A 5.49 kB -
6948ada0-HASH.js gzip N/A 62.5 kB -
9544-HASH.js gzip N/A 57.7 kB -
Total 230 kB 231 kB ⚠️ +622 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 180 B 🟢 3 B (-2%)
css-HASH.js gzip 331 B 330 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 351 B 352 B
hooks-HASH.js gzip 384 B 383 B
image-HASH.js gzip 580 B 581 B
index-HASH.js gzip 260 B 260 B
link-HASH.js gzip 2.49 kB 2.49 kB
routerDirect..HASH.js gzip 320 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.97 kB 7.97 kB ✅ -1 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 126 kB 126 kB
page.js gzip 249 kB 251 kB 🔴 +2.35 kB (+1%)
Total 375 kB 378 kB ⚠️ +2.26 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 614 B 616 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 33.3 kB 33 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 34.9 kB 34.6 kB ✅ -281 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 733 B 735 B
Total 733 B 735 B ⚠️ +2 B
Build Cache
Canary PR Change
0.pack gzip 3.84 MB 3.89 MB 🔴 +47.6 kB (+1%)
index.pack gzip 102 kB 103 kB
index.pack.old gzip 102 kB 104 kB 🔴 +2.04 kB (+2%)
Total 4.04 MB 4.09 MB ⚠️ +50.3 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 316 kB 322 kB 🔴 +5.91 kB (+2%)
app-page-exp..prod.js gzip 168 kB 171 kB 🔴 +3.4 kB (+2%)
app-page-tur...dev.js gzip 315 kB 321 kB 🔴 +5.88 kB (+2%)
app-page-tur..prod.js gzip 167 kB 171 kB 🔴 +3.43 kB (+2%)
app-page-tur...dev.js gzip 312 kB 318 kB 🔴 +5.77 kB (+2%)
app-page-tur..prod.js gzip 166 kB 169 kB 🔴 +3.47 kB (+2%)
app-page.run...dev.js gzip 312 kB 318 kB 🔴 +5.81 kB (+2%)
app-page.run..prod.js gzip 166 kB 169 kB 🔴 +3.44 kB (+2%)
app-route-ex...dev.js gzip 70.5 kB 70.6 kB
app-route-ex..prod.js gzip 49 kB 49.1 kB
app-route-tu...dev.js gzip 70.5 kB 70.6 kB
app-route-tu..prod.js gzip 49 kB 49.1 kB
app-route-tu...dev.js gzip 70.1 kB 70.2 kB
app-route-tu..prod.js gzip 48.8 kB 48.9 kB
app-route.ru...dev.js gzip 70.1 kB 70.2 kB
app-route.ru..prod.js gzip 48.8 kB 48.9 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 43.2 kB 50.5 kB 🔴 +7.31 kB (+17%)
pages-api-tu..prod.js gzip 32.9 kB 37.5 kB 🔴 +4.68 kB (+14%)
pages-api.ru...dev.js gzip 43.2 kB 50.5 kB 🔴 +7.31 kB (+17%)
pages-api.ru..prod.js gzip 32.8 kB 37.5 kB 🔴 +4.67 kB (+14%)
pages-turbo....dev.js gzip 52.5 kB 59.9 kB 🔴 +7.37 kB (+14%)
pages-turbo...prod.js gzip 39.4 kB 44.1 kB 🔴 +4.75 kB (+12%)
pages.runtim...dev.js gzip 52.5 kB 59.8 kB 🔴 +7.38 kB (+14%)
pages.runtim..prod.js gzip 39.4 kB 44.1 kB 🔴 +4.75 kB (+12%)
server.runti..prod.js gzip 62.7 kB 68.3 kB 🔴 +5.6 kB (+9%)
app-page-exp...dev.js gzip N/A 325 kB -
app-page-exp..prod.js gzip N/A 173 kB -
app-page-nod...dev.js gzip N/A 321 kB -
app-page-nod..prod.js gzip N/A 171 kB -
app-page-tur...dev.js gzip N/A 324 kB -
app-page-tur..prod.js gzip N/A 173 kB -
app-page-tur...dev.js gzip N/A 321 kB -
app-page-tur..prod.js gzip N/A 171 kB -
app-route-ex...dev.js gzip N/A 77.7 kB -
app-route-ex..prod.js gzip N/A 53.6 kB -
app-route-no...dev.js gzip N/A 77.3 kB -
app-route-no..prod.js gzip N/A 53.4 kB -
app-route-tu...dev.js gzip N/A 77.7 kB -
app-route-tu..prod.js gzip N/A 53.6 kB -
app-route-tu...dev.js gzip N/A 77.3 kB -
app-route-tu..prod.js gzip N/A 53.4 kB -
dist_client_...dev.js gzip N/A 332 B -
dist_client_...dev.js gzip N/A 324 B -
dist_client_...dev.js gzip N/A 334 B -
dist_client_...dev.js gzip N/A 326 B -
Total 2.8 MB 5.4 MB ⚠️ +2.6 MB
📝 Changed Files (45 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-nod..ntime.dev.js
  • app-page-nod..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • ... and 25 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js

Diff too large to display

app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-nod..ntime.dev.js
failed to diff
app-page-nod..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js

Diff too large to display

app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-no..ntime.dev.js

Diff too large to display

app-route-no..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-experimental-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-turbo-experimental-nodestreams.runtime.dev.js.map
\ No newline at end of file
dist_client_..ntime.dev.js
@@ -0,0 +1,2 @@
+"use strict";exports.ids=["dist_client_dev_noop-turbopack-hmr_js"],exports.modules={"./dist/client/dev/noop-turbopack-hmr.js"(module,exports1){function connect(){}Object.defineProperty(exports1,"__esModule",{value:!0}),Object.defineProperty(exports1,"connect",{enumerable:!0,get:function(){return connect}}),("function"==typeof exports1.default||"object"==typeof exports1.default&&null!==exports1.default)&&void 0===exports1.default.__esModule&&(Object.defineProperty(exports1.default,"__esModule",{value:!0}),Object.assign(exports1.default,exports1),module.exports=exports1.default)}};
+//# sourceMappingURL=dist_client_dev_noop-turbopack-hmr_js-turbo-nodestreams.runtime.dev.js.map
\ No newline at end of file
pages-api-tu..ntime.dev.js

Diff too large to display

pages-api-tu..time.prod.js

Diff too large to display

pages-api.runtime.dev.js

Diff too large to display

pages-api.ru..time.prod.js

Diff too large to display

pages-turbo...ntime.dev.js

Diff too large to display

pages-turbo...time.prod.js

Diff too large to display

pages.runtime.dev.js

Diff too large to display

pages.runtime.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch from e3855fa to 2befd89 Compare February 6, 2026 03:19
Copy link
Copy Markdown
Contributor Author

@feedthejim feedthejim left a comment

Choose a reason for hiding this comment

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

Should also rename the web methods to be more explicit

pnpm build # Required before running tests (Turborepo dedupes if unchanged)
```

## Bundler Selection
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm surprised this wasn't in there

AGENTS.md Outdated
- `pnpm test-start-turbo` - Production build+start with Turbopack
- `pnpm test-start-webpack` - Production build+start with Webpack

**Run tests headless** (no browser window): Always set `HEADLESS=true` when running e2e tests:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Probably should be more flexible

AGENTS.md Outdated

**CI Analysis Tips:**

- **Assume test failures are NOT flaky by default.** Investigate every failure as if it is caused by the current changes until proven otherwise.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Should say check for historical data

@feedthejim
Copy link
Copy Markdown
Contributor Author

definitely not exhaustive around the PPR rendering pipeline

@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch 2 times, most recently from 52ec009 to 1c3eac6 Compare February 6, 2026 19:52
@feedthejim
Copy link
Copy Markdown
Contributor Author

feedthejim commented Feb 6, 2026

dev is hanging now :(
build still fails

@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch 3 times, most recently from 827f41c to 436e2d4 Compare February 9, 2026 02:04
@feedthejim feedthejim changed the base branch from canary to stack/node-stream-rewrite-ci-coverage February 9, 2026 02:04
@feedthejim feedthejim changed the base branch from stack/node-stream-rewrite-ci-coverage to stack/node-stream-rewrite-cache-fix February 9, 2026 03:12
| import('node:stream').Readable
let streamIterator: AsyncIterable<Uint8Array>

if (process.env.__NEXT_USE_NODE_STREAMS) {
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

there's no point forking the impl into node/web inside this file, we can just use node streams everywhere. i did that at some point but then rolled it back while debugging and never brought it back

(at the end of createCombinedPayloadStream we return a node Readable anyway, and in other places we immediately iterate over the stream to collect its chunks)

filterStackFrame,
debugChannel: debugChannel?.serverSide,
},
(fn) => workUnitAsyncStorage.run(requestStore, fn)
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

this runInContext business feels weird, why can't we just wrap this whole thing in a workUnitAsyncStorage.run(requestStore, () => ...)? same for ReactServerResult

Comment on lines +23 to +26
export function teeNodeReadable(
source: NodeReadable,
runInContext: <T>(fn: () => T) => T = (fn) => fn()
): [NodeReadable, NodeReadable] {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i haven't reviewed the implementation but do we actually need to replicate how ReadableStream#tee() works? it's a pain that tee-ing consumes the original stream, and i don't think we need to do that with node streams, so why make it more painful than it has to be, when the API could probably just be

const readable = ...
const teed = teeNodeReadable(readable)

Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

also. what's with the runInContext again. we've never needed things like this, why are they needed now

Comment on lines +67 to +69
// ---------------------------------------------------------------------------
// Continue functions (replaces ~8 large if/else blocks in app-render.tsx)
// ---------------------------------------------------------------------------
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

"(replaces ~8 large if/else blocks in app-render.tsx)" is not useful as a code comment

/**
* Creates a stream that never emits data (used for resume-and-abort patterns).
*/
export function createPendingStream(): AnyStream {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i don't know if this is new in this PR or just mirroring existing code, but creating a stream that never ends without some kind of abortSignal seems smelly

*/
export function getServerPrerender(
ComponentMod: ServerPrerenderComponentMod
): (...args: any[]) => any {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should have a real type

Comment on lines +94 to +102
// With createFromNodeStream + debugChannel, the response can resolve
// before debug stream completion. In restart-on-cache-miss flows this
// can leave metadata in the body instead of the head for the request
// render. Wait for debug completion for request work units.
waitForDebugEnd = new Promise<void>((resolve, reject) => {
nodeDebugStream!.once('end', resolve)
nodeDebugStream!.once('close', resolve)
nodeDebugStream!.once('error', reject)
})
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

this doesn't make sense to me and i suspect this whole waitForDebugEnd thing is hallucinated

Comment on lines +87 to +91
// Cast through unknown: global ReadableStream and stream/web.ReadableStream
// differ slightly in type declarations.
nodeDebugStream = Readable.fromWeb(
debugStream as unknown as import('stream/web').ReadableStream<Uint8Array>
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this needed? previously we've manually ensured that both streams are the same type
(it's not visible on the types of the component which i dislike but that's how it works in practice)

// ---------------------------------------------------------------------------

export function nodeStreamFromString(str: string): Readable {
const { PassThrough: PT } = getNodeStream()
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

is there a reason we need to alias this? i'd rather just have PassThrough

) => boolean)
| undefined
onError?: (error: unknown) => void
signal?: AbortSignal
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

Comment on lines +237 to +244
filterStackFrame:
| ((
url: string,
functionName: string,
lineNumber: number,
columnNumber: number
) => boolean)
| undefined
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment on lines +254 to +256
pipe<Writable extends NodeJS.WritableStream>(
destination: Writable
): Writable
Copy link
Copy Markdown
Member

@lubieowoce lubieowoce Feb 9, 2026

Choose a reason for hiding this comment

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

confusing naming of generic param, it looks like it's referring to node's Writable when it's not

Suggested change
pipe<Writable extends NodeJS.WritableStream>(
destination: Writable
): Writable
pipe<Destination extends NodeJS.WritableStream>(
destination: Destination
): Destination

...options,
onHeaders: wrappedOnHeaders,
onShellReady() {
pipe(passthrough as unknown as Writable)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we need all these casts

Context:
- Use Node native Buffer.indexOf in indexOfUint8Array when available.
- Use subarray instead of slice in removeFromUint8Array to reduce copies.

Benchmark (micro scenario, 300 iterations, 30 warmup, parent-file swap):
- Web continueStaticPrerender median: 4.873ms -> 0.658ms (+86.50% faster)
- Web continueDynamicHTMLResume median: 4.868ms -> 0.604ms (+87.59% faster)
Context:
- Extract shared flight payload encoding helpers and avoid repeated JSON wrapper construction for binary chunks.
- Use chunked base64 conversion fallback and keep output format identical for client hydration scripts.

Benchmark (micro scenario, 300 iterations, 30 warmup, file-swap A/B):
- Web continueStaticPrerender median: 5.881ms -> 0.674ms (+88.55% faster)
- Web continueDynamicHTMLResume median: 5.708ms -> 0.773ms (+86.46% faster)
- Web continueDynamicPrerender median: 0.255ms -> 0.269ms (-5.35%)
- Web continueDynamicHTMLResume (utf8 flight) median: 1.187ms -> 1.329ms (-11.93%)
Context:
- Avoid repeated Buffer.concat in createBufferedTransformNode by buffering chunks and flushing once.
- Keep callback timing unchanged while reducing per-chunk allocation overhead.

Benchmark (micro scenario, 300 iterations, 30 warmup, parent-file swap):
- createBufferedTransformNode only median: 0.070ms -> 0.052ms (+26.85% faster)
- Node continueDynamicHTMLResume median: 0.657ms -> 0.571ms (+13.01% faster)
- Node continueStaticPrerender median: 0.609ms -> 0.611ms (-0.40%)
Context:
- Build node flight script frames using fewer temporary arrays/objects.
- Keep output bytes identical while reducing framing overhead for large payloads.

Benchmark (micro scenario, 300 iterations, 30 warmup, parent-file swap):
- createInlinedDataNodeStream only median: 0.456ms -> 0.415ms (+9.07% faster)
- createInlinedDataNodeStream (utf8) median: 0.884ms -> 0.825ms (+6.73% faster)
- Node continueDynamicHTMLResume median: 0.657ms -> 0.571ms (+12.98% faster)
Context:
- Replace Array.shift in tee queue handling with index-based O(1) dequeue.
- Preserve backpressure semantics and callback order.

Benchmark (micro scenario, 300 iterations, 30 warmup, parent-file swap):
- teeNodeReadable median: 0.072ms -> 0.071ms (+0.70% faster)
- Node continueDynamicHTMLResume median: 0.586ms -> 0.571ms (+2.41% faster)
- Node continueStaticPrerender median: 0.593ms -> 0.611ms (-3.15%)
@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch from 1296685 to 99a39cf Compare February 11, 2026 03:18
@feedthejim feedthejim force-pushed the feedthejim/node-stream-rewrite branch from 69f2ea6 to bca423c Compare February 11, 2026 06:20
Move detailed runtime internals documentation (experimental flags,
pre-compiled bundles, DCE patterns, React vendoring) from always-loaded
AGENTS.md into focused skills under .agents/skills/. AGENTS.md keeps
one-liner guardrails and points to skills for deep-dive workflows.

Skills added:
- flags: feature-flag wiring end-to-end
- dce-edge: DCE-safe require patterns and edge constraints
- react-vendoring: entry-base boundaries and vendored React
- runtime-debug: bundle regression diagnosis workflow
- pr-status-triage: CI failure and PR review triage
- authoring-skills: how to create and maintain skills

AGENTS.md: 489 -> 426 lines (-13%), deep content now loads on demand.
benfavre added a commit to benfavre/next.js that referenced this pull request Mar 18, 2026
Add `node-stream-helpers.ts` with Node.js native stream utilities that
parallel the WhatWG stream helpers in `node-web-streams-helper.ts`.
These are the foundational building blocks needed for the node-streams
rendering effort (PRs vercel#89566, vercel#89859, vercel#89860, vercel#90500).

Key functions:
- `chainNodeStreams()` - chains multiple Readable streams sequentially
- `createBufferedTransformNode()` - batches small chunks before flushing
- `createInlinedDataNodeStream()` - inlines flight data into HTML stream
- `pipeNodeReadableToResponse()` - pipes Readable directly to ServerResponse
- `nodeStreamToBuffer()` / `nodeStreamToString()` - collection utilities

ALS context propagation uses `bindSnapshot()` from the existing
`async-local-storage.ts` module, which wraps `AsyncLocalStorage.bind()`.
This addresses the review feedback from @lubieowoce on PR vercel#89859 where
ALS context was incorrectly propagated by wrapping callback return values
instead of binding the callbacks themselves.

This PR adds only the helper utilities as new files. No existing files
are modified. Wiring into the render pipeline is a separate step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
benfavre added a commit to benfavre/next.js that referenced this pull request Mar 18, 2026
…eams flag

Add `experimental.useNodeStreams` config flag that switches the stream
operations module (stream-ops.ts) to load native Node.js implementations
for hot-path functions:

- chainStreams: uses chainNodeStreams (PassThrough-based sequential piping)
- streamToBuffer: uses nodeStreamToBuffer (for-await on Node Readable)
- streamToString: uses nodeStreamToString (streaming TextDecoder)
- renderToFizzStream: uses renderToPipeableStream instead of
  renderToReadableStream, avoiding web→node conversion overhead in React

Complex transform chains (continueFizzStream, prerender continuations)
still delegate to the web implementation as a stopgap — the native
buffering and data inlining transforms from node-stream-helpers will be
wired in a follow-up once the web transform chain is decomposed.

Includes the node-stream-helpers module from PR vercel#91580 which provides
the underlying native stream utilities.

References: vercel#91580, vercel#89566, vercel#90500

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@benfavre
Copy link
Copy Markdown
Contributor

Related work: standalone helpers + wiring PRs

We've created complementary PRs that contribute to this effort:

Why native streams > fast-webstreams

We also benchmarked `experimental-fast-webstreams` (#89686) on Node.js v25.7.0 and found it's -6.7% slower on complex routes (10 nested layouts). Node 25's native WhatWG streams have improved enough that the fast-webstreams interop layer adds overhead. Native Node.js streams bypass the WhatWG API entirely, which is the better long-term path.

Additional perf PRs (benchmarked +11.5% on web streams path)

We also have 14 micro-optimization PRs (#91559-#91577) targeting non-stream hotspots (structuredClone elimination in metadata resolution at 914ms, tracer early exit, pre-compiled regex, O(1) header lookups, etc.) that deliver +11.5% throughput independently of the stream implementation.

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.

5 participants