Skip to content

stream: trim per-pipe and per-tee costs in webstreams - #66154

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

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

Conversation

@mcollina

Copy link
Copy Markdown
Member

Round 18 of the webstreams performance work (follows #66052). This one targets the fixed cost of wiring up a pipe or a tee, and a few per-read costs that survived the earlier rounds: an internal read request that was rebuilt through the runtime for every pipe, tee and BYOB tee read, a pull cycle that ran for sources that have no pull(), and two async wrappers on the tee and BYOB read paths.

Read requests as a class

An object literal with computed symbol keys ({ [kChunk]() {}, [kClose]() {}, [kError]() {} }) is rebuilt through the runtime every time it is evaluated, close to a microsecond each (~840 ns vs ~8 ns for a class instance on this machine). pipeTo, tee and the byte tee materialized their internal read request that way once per pipe or tee, and the byte tee's BYOB path did so on every read. The requests are now instances of one small class holding the three step functions.

Skip the pull cycle for sources without pull()

A source without pull() still ran the pull bookkeeping on every read and once at start: two reaction closures and a microtask whose only effect was to clear the pulling flag. Both controllers now return early when the pull algorithm is the shared no-op. Push-style sources (start() enqueues everything, ReadableStream.from-style feeders, new ReadableStream() with no source) hit this on every read.

Drop two async wrappers

The default tee's pull algorithm was an async function and the byte tee's returned a fresh resolved promise; both now return nothing, which reaches the controller's pull-fulfilled step at the same microtask position without the promise. ReadableStreamBYOBReader.prototype.read() was an async method, so the read request's promise was adopted through a wrapper (an extra promise and two microtask hops per read). It now returns the request's promise directly, as the spec does; argument errors still become rejections. BYOB reads therefore settle two microtasks earlier, which is the spec's timing.

TransformStream without start()

The start promise record is no longer allocated when the transformer has no start(): both sides adopt a promise that is already resolved either way.

Benchmarks

node benchmark/compare.js --runs 20 webstreams (lifecycle, creation, tee, readable-read, readable-read-buffered, pipe-through, pipe-to, readable-async-iterator, from, js_transfer); only the significant rows listed, everything else is within noise:

                                                              confidence improvement accuracy
webstreams/lifecycle.js kind='pipe-to' n=50000                       ***     21.69 %      ±10.01%
webstreams/lifecycle.js kind='pipe-through' n=50000                  ***     14.29 %       ±3.71%
webstreams/creation.js kind='TransformStream' n=50000                ***      9.16 %       ±2.94%
webstreams/tee.js type='normal' n=100000                             ***      7.27 %       ±2.29%
webstreams/js_transfer.js n=10000 payload='WritableStream'           ***      4.98 %       ±1.86%
webstreams/js_transfer.js n=10000 payload='ReadableStream'           ***      4.86 %       ±0.83%
webstreams/readable-read.js type='byob' n=100000                     ***      3.64 %       ±1.79%
webstreams/js_transfer.js n=10000 payload='TransformStream'          ***      3.49 %       ±1.02%

Three rows were flagged negative in the first run (creation.js kind='ReadableStreamBYOBReader', pipe-through.js kind='default', one pipe-to.js configuration); re-run at 30 runs they all sit inside their intervals (−2.6 % ±4.1 %, −1.2 % ±1.7 %, and the pipe-to rows mixed between −1.7 % and +2.4 %).

The official benchmarks drive their sources through pull(); the shapes this round is really about gain more. On this machine, interleaved runs of a small harness: a pull-driven pipeTo +27 %, new ReadableStream().tee() +31 %, new ReadableStream() +34 % (the no-op pull cycle at start is gone), new TransformStream() +12 %, create → 4×1KB → pipeThrough → close +18 %, BYOB reads +6 %.

Ordering is unchanged apart from the BYOB timing above: a 48-scenario microtask-ordering stress (start variants, push-only sources, BYOB read timing and argument errors, byte tee with BYOB readers, transform without start(), pipeTo shutdown paths) logs identically against main except that BYOB reads settle two ticks earlier; every consumer's own event sequence is byte-identical. WPT streams and the webstreams parallel batch are green.


AI generated, humanly reviewed.

An object literal with computed symbol keys is rebuilt through the
runtime every time it is evaluated, close to a microsecond each. pipeTo,
tee and the byte tee materialized their internal read request as such
a literal once per pipe or tee, and the byte tee's BYOB path did so on
every read. The requests are now instances of one small class holding
the three step functions.

A source without pull() ran the pull bookkeeping anyway: two reaction
closures and a microtask per read whose only effect was to clear the
pulling flag. Push-style sources now skip it.

The default tee's pull algorithm was an async function and the byte
tee's returned a fresh resolved promise; both now return nothing, which
reaches the controller's pull-fulfilled step at the same microtask
position without the promise.

ReadableStreamBYOBReader.prototype.read() was an async method, so the
read request's promise was adopted through a wrapper (an extra promise
and two microtask hops per read). It now returns the request's promise
directly, as the spec does; argument errors still become rejections.

A TransformStream without start() no longer allocates the start promise
record: the sides adopt a promise that is already resolved either way.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@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 Sep 20, 2026
@mcollina
mcollina marked this pull request as ready for review September 20, 2026 12:59

[kError](error) {
this.errorSteps(error);
}

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.

nit: if StepsReadRequest is internal, why use symbol properties?

@codecov

codecov Bot commented Sep 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.29%. Comparing base (f466c0c) to head (5e6c052).
⚠️ Report is 11 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main   #66154   +/-   ##
=======================================
  Coverage   90.28%   90.29%           
=======================================
  Files         790      790           
  Lines      271974   272023   +49     
  Branches    51931    51920   -11     
=======================================
+ Hits       245561   245625   +64     
+ Misses      16901    16899    -2     
+ Partials     9512     9499   -13     
Files with missing lines Coverage Δ
lib/internal/webstreams/readablestream.js 98.07% <100.00%> (+0.02%) ⬆️
lib/internal/webstreams/transformstream.js 98.72% <100.00%> (+0.01%) ⬆️

... and 27 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.

4 participants