Skip to content

stream: trim webstreams start and construction costs - #66643

Open
mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round22
Open

mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round22

Conversation

@mcollina

@mcollina mcollina commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Rounds 21 and 22 of the webstreams performance work in one commit (supersedes #66424, which this includes). It targets what a stream costs before its first chunk and after its last one: construction of ReadableStream, WritableStream and TransformStream, tee() and pipeTo() setup and shutdown.

Idle start steps and closed watchers (round 21)

No post-start microtask for sources without pull()

setupReadableStreamDefaultController queued a microtask after every start() to flip the controller's started flag and call pull() if needed. That flag only gates calls into the pull algorithm, so for a source without pull() (push-style sources that enqueue from start(), or new ReadableStream()) the step has nothing observable left to do. The flag is now set synchronously in that case and no closure or task is allocated. Sources with pull() keep the microtask, at the same position. Byte streams get the same treatment.

This also stops a burst of short-lived streams from being kept alive until the next microtask checkpoint, which is where most of the time in the creation benchmark went.

Closed watchers as records instead of promises

pipeTo() holds the only references to its reader and writer, and tee() to its reader, so their [[closedPromise]] records are never observed as promises. Each pipe still materialized both records plus a reaction to watch for close and error, and each tee one record plus a reaction. The watchers are now installed as the records themselves (ClosedPromiseHook), the way the pipe's ready hook already worked: the settle sites resolve or reject the record where they would have settled the promise, and the hook enqueues the watcher at the microtask position its reaction would have had. The erroring and release paths probe the record's promise, so every hook carries one shared forever-pending promise, which setPromiseHandled() skips.

The tee's cancel promise is materialized by the first branch cancel only.

TransformStream construction (round 22)

Profiling the creation benchmarks showed they are dominated by the scavenger rather than by JavaScript: a start step pending in the microtask queue keeps the whole stream graph live across young-generation collections, so the cost of a creation burst follows the bytes each stream leaves pending and retained (a TransformStream went from 6.1 µs to 0.9 µs per construction with a 128 MB semi-space). A TransformStream without start() left about three kilobytes behind: five closures for the sink and source algorithms of its two sides, and per side a wrapper promise, a thenable job, two reactions and two closures to adopt the start promise.

Shared sink and source algorithms

The five algorithms are now module-level functions. They reach the transform stream through a transformStream field on the state of the readable and writable controller they serve, and the close, abort and cancel algorithms receive the controller as a trailing argument for that (the wrappers around user sinks and sources ignore it).

One start delivery for both sides

Per spec the start promise is resolved with the transformer's start result at the end of construction, and each side then adopts it through a wrapper promise. transformStreamStart() takes the same microtask hops with reactions on one shared promise: three after construction for a non-thenable start result, two after the adopting promise settles for a thenable one, with the rejection path erroring both sides in the same order as before. The readable and writable controllers expose their post-start steps for that (readableStreamDefaultControllerStarted(), writableStreamDefaultControllerStarted() / StartFailed()), and a kDeferredStart start result tells the setup to leave the step to the caller. User-facing streams keep the existing wrapper.

Lazy transfer state

The transfer record of ReadableStream and WritableStream state is materialized on first transfer instead of per stream.

Tests

test/parallel/test-whatwg-readablestream-tee-cancel-settle.js covers the tee cancel promise settling before and after materialization, for default and byte streams. A 53-scenario microtask-ordering stress (pull, push, iterators, tee, every pipeTo shutdown path, transform backpressure, ReadableStream.from, writers) and a 27-scenario start-timing probe (no start(), start() returning undefined, a resolved, pending, late-resolved, late-rejected or rejected promise, a thenable object, a sync throw, close/cancel/abort/terminate/enqueue/error during start, pipe-through, backpressure, writable and readable starts, transfer) logs identical microtask ticks against main. WPT streams/encoding/compression and the webstreams parallel batch are green.

Benchmark

node benchmark/compare.js --runs 20 over benchmark/webstreams, round 22 on top of round 21 (the round-21 numbers against main are in #66424; combined, creation ReadableStream is +172 %, TransformStream +67 %, tee +31 %, readers +15 %, lifecycle pipe-through +14 %, pipe-to +11 %):

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                              -1.27 %       ±2.95%  ±3.95%  ±5.19%
webstreams/adapters.js kind='readable-to-web' n=100000                                -1.67 %       ±3.26%  ±4.37%  ±5.75%
webstreams/adapters.js kind='writable-from-web' n=100000                               1.80 %       ±5.60%  ±7.54% ±10.02%
webstreams/adapters.js kind='writable-to-web' n=100000                                -1.43 %       ±4.00%  ±5.36%  ±7.05%
webstreams/creation.js kind='ReadableStream.tee' n=50000                      ***      9.04 %       ±3.85%  ±5.19%  ±6.90%
webstreams/creation.js kind='ReadableStream' n=50000                                   7.47 %      ±10.48% ±14.05% ±18.53%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                         0.93 %       ±9.81% ±13.20% ±17.47%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000                      1.76 %       ±8.73% ±11.69% ±15.39%
webstreams/creation.js kind='TransformStream' n=50000                         ***     66.74 %       ±8.17% ±10.99% ±14.55%
webstreams/creation.js kind='WritableStream' n=50000                                   0.97 %       ±3.86%  ±5.17%  ±6.79%
webstreams/encoding-streams.js len=1024 kind='decode' n=100000                         2.17 %       ±4.77%  ±6.40%  ±8.45%
webstreams/encoding-streams.js len=1024 kind='encode' n=100000                         1.12 %       ±3.02%  ±4.05%  ±5.34%
webstreams/encoding-streams.js len=16 kind='decode' n=100000                           2.32 %       ±5.21%  ±7.00%  ±9.25%
webstreams/encoding-streams.js len=16 kind='encode' n=100000                          -1.38 %       ±4.10%  ±5.52%  ±7.33%
webstreams/from.js kind='async' n=1000000                                             -2.42 %       ±4.79%  ±6.42%  ±8.46%
webstreams/from.js kind='sync' n=1000000                                              -0.61 %       ±6.29%  ±8.43% ±11.08%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                            -1.46 %       ±2.59%  ±3.48%  ±4.59%
webstreams/js_transfer.js n=10000 payload='TransformStream'                           -1.22 %       ±2.10%  ±2.82%  ±3.71%
webstreams/js_transfer.js n=10000 payload='WritableStream'                             0.22 %       ±2.39%  ±3.20%  ±4.22%
webstreams/lifecycle.js kind='async-iterator' n=50000                                  0.85 %       ±3.17%  ±4.25%  ±5.59%
webstreams/lifecycle.js kind='pipe-through' n=50000                           ***     13.89 %       ±3.69%  ±4.98%  ±6.62%
webstreams/lifecycle.js kind='pipe-to' n=50000                                         3.68 %       ±6.69%  ±8.97% ±11.82%
webstreams/lifecycle.js kind='readable' n=50000                                       -0.09 %       ±2.94%  ±3.94%  ±5.18%
webstreams/pipe-through.js kind='default' n=500000                                    -0.84 %       ±1.69%  ±2.26%  ±2.98%
webstreams/pipe-through.js kind='transform' n=500000                                  -1.35 %       ±2.08%  ±2.79%  ±3.66%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000                       0.93 %       ±2.70%  ±3.64%  ±4.83%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000                    0.12 %       ±2.04%  ±2.74%  ±3.62%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000                   -0.41 %       ±2.39%  ±3.20%  ±4.21%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000                    1.03 %       ±1.87%  ±2.50%  ±3.30%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000                -0.99 %       ±1.59%  ±2.13%  ±2.80%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000                 1.03 %       ±1.60%  ±2.15%  ±2.82%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000                    0.29 %       ±2.16%  ±2.89%  ±3.81%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000                -1.95 %       ±2.31%  ±3.11%  ±4.10%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000                -0.99 %       ±2.20%  ±2.94%  ±3.87%
webstreams/readable-async-iterator.js type='bytes' n=100000                     *     -2.82 %       ±2.12%  ±2.83%  ±3.73%
webstreams/readable-async-iterator.js type='normal' n=100000                           0.37 %       ±5.63%  ±7.55%  ±9.94%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                             4.10 %       ±5.03%  ±6.74%  ±8.87%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                     *      6.22 %       ±6.03%  ±8.08% ±10.62%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -1.66 %       ±5.49%  ±7.37%  ±9.71%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                          0.58 %       ±5.46%  ±7.31%  ±9.63%
webstreams/readable-read.js type='byob' n=100000                                      -1.00 %       ±1.83%  ±2.45%  ±3.23%
webstreams/readable-read.js type='normal' n=100000                                    -1.96 %       ±4.01%  ±5.39%  ±7.12%
webstreams/tee.js type='bytes' n=100000                                                0.10 %       ±1.85%  ±2.47%  ±3.26%
webstreams/tee.js type='normal' n=100000                                               0.91 %       ±2.58%  ±3.46%  ±4.55%
webstreams/writable-write.js type='await' n=100000                                    -1.02 %       ±4.02%  ±5.39%  ±7.10%
webstreams/writable-write.js type='queued' n=100000                                   -0.45 %       ±1.97%  ±2.64%  ±3.49%

The readable-async-iterator.js type='bytes' row (untouched code) re-run directly with 30 samples:

                                                             confidence improvement accuracy (*)   (**)  (***)
webstreams/readable-async-iterator.js type='bytes' n=100000                 -0.57 %       ±2.02% ±2.69% ±3.51%
webstreams/readable-async-iterator.js type='normal' n=100000                -1.41 %       ±3.55% ±4.73% ±6.19%

The per-chunk rows are flat as expected: the savings are per stream. A local harness that constructs a TransformStream measured +67 %.


AI generated, humanly reviewed.

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. web streams Issues and PRs related to the Web Streams API. labels Oct 10, 2026
@mcollina
mcollina requested review from MattiasBuelens and anonrig and removed request for MattiasBuelens October 10, 2026 12:07
@mcollina
mcollina marked this pull request as ready for review October 10, 2026 12:08
A source without pull() has nothing observable left to do in its
post-start step, since the started flag only gates calls into the pull
algorithm. Set the flag right away instead of from a microtask, so a
push-style ReadableStream allocates neither the closure nor the task
and is not kept alive until the next microtask checkpoint.

pipeTo and tee hold the only references to their reader and writer, so
their [[closedPromise]] records are never observed as promises. Install
the watchers as the records themselves, as pipeTo's ready hook already
does, instead of materializing a promise plus reaction per side, and
hand the erroring/release probes one shared pending promise. The tee's
cancel promise is likewise materialized by the first branch cancel.

Constructing a TransformStream allocated five closures for the sink and
source algorithms of its two sides, and each side adopted the start
promise through a wrapper promise plus a thenable job, so a stream
without a start() left about three kilobytes pending in the microtask
queue until the started steps ran. Per-stream creation bursts spend
most of their time copying that graph through the scavenger. The sink
and source algorithms are now shared functions that reach the transform
stream through a field on their controller state (the controller is
passed to the close, abort and cancel algorithms for that), and the
post-start steps of both sides are delivered by one reaction chain on a
shared promise, taking the same microtask hops as the spec's start
promise adoption: three after construction for a non-thenable start
result, two after the adopting promise settles for a thenable one. The
readable and writable transfer state records are materialized on first
transfer instead of per stream.

Microtask ordering is unchanged throughout: each hook and start step
runs at the position the promise reaction would have had.

node benchmark/compare.js --runs 20 over benchmark/webstreams (46 rows,
all others within the confidence interval):

webstreams/creation.js kind='ReadableStream'               ***  +172.40%
webstreams/creation.js kind='TransformStream'              ***   +66.74%
webstreams/creation.js kind='ReadableStream.tee'           ***   +31.36%
webstreams/creation.js kind='ReadableStreamBYOBReader'     ***   +15.66%
webstreams/creation.js kind='ReadableStreamDefaultReader'  ***   +14.53%
webstreams/lifecycle.js kind='pipe-through'                ***   +13.89%
webstreams/lifecycle.js kind='pipe-to' (40 runs)           **    +10.98%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round22 branch from b4189e2 to 334b151 Compare October 10, 2026 12:08
@mcollina mcollina changed the title stream: trim TransformStream construction costs stream: trim webstreams start and construction costs Oct 10, 2026
@codecov

codecov Bot commented Oct 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.54651% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.46%. Comparing base (4e09bf3) to head (334b151).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/readablestream.js 98.00% 4 Missing ⚠️
lib/internal/webstreams/writablestream.js 97.87% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66643      +/-   ##
==========================================
+ Coverage   90.43%   90.46%   +0.02%     
==========================================
  Files         791      791              
  Lines      276605   276996     +391     
  Branches    53117    53230     +113     
==========================================
+ Hits       250155   250577     +422     
+ Misses      16866    16836      -30     
+ Partials     9584     9583       -1     
Files with missing lines Coverage Δ
lib/internal/webstreams/transformstream.js 98.78% <100.00%> (+0.06%) ⬆️
lib/internal/webstreams/util.js 97.20% <100.00%> (+0.12%) ⬆️
lib/internal/webstreams/writablestream.js 99.52% <97.87%> (+<0.01%) ⬆️
lib/internal/webstreams/readablestream.js 98.11% <98.00%> (+0.02%) ⬆️

... and 28 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. web streams Issues and PRs related to the Web Streams API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants