Skip to content

stream: cut promise churn in webstreams hot paths - #65138

Merged
aduh95 merged 3 commits into
nodejs:mainfrom
mcollina:webstream-perf-round12
Aug 15, 2026
Merged

stream: cut promise churn in webstreams hot paths#65138
aduh95 merged 3 commits into
nodejs:mainfrom
mcollina:webstream-perf-round12

Conversation

@mcollina

@mcollina mcollina commented Aug 8, 2026

Copy link
Copy Markdown
Member

Twelfth round of pure-JS webstreams optimizations, following #64890.

Profiling pipeTo at the default highWaterMark showed roughly 10% of the profile in queueMicrotask plus its native binding (one call per pump batch), a fresh writer.ready promise record plus reaction per backpressure flip on the pipeThrough shape, and an implicit async-wrapper promise per sink.write/source.pull invocation. Three changes, one commit:

  • Non-thenable algorithm fast path. User sink.write and source.pull callbacks are wrapped without coercing their result into a promise. A non-thenable result (the common synchronous case) means fulfillment is guaranteed and no then() lookup is observable, so the fulfilled reaction is enqueued through a single shared resolved promise at the exact microtask position the coerced promise's reaction would have had. Thenable results go through PromiseResolve(), matching the reference implementation's promiseCall (identity for native promises).
  • pipeTo parks on backpressure via a ready-record hook. The pump installs a record that duck-types the writer's lazily-materialized [[readyPromise]] record; writableStreamUpdateBackpressure resolving it re-enters the pump directly. This removes the per-flip promise record + reaction and the per-batch queueMicrotask. The backpressure state field is now published before the ready record is resolved so the hook observes the new value (the resolve of a real ready record only settles a promise, so the reorder is unobservable otherwise).
  • Chunk-forwarding microtasks via a shared resolved promise. The pipeTo/tee forwardChunk hops and the pump's between-batch yield use a reaction on the shared resolved promise instead of queueMicrotask, which enqueues at the same position with less overhead.

A separate first commit fixes the pipe-to.js benchmark: the highWaterMark values were passed inside the underlying source/sink dictionaries where they are ignored, so all 16 configurations measured the identical workload at the default highWaterMark of 1. The strategies are now passed as the constructors' second argument, with the matrix covering the default (1) and buffered (1024, 4096) configurations.

Benchmark results with the fixed benchmark (30 runs):

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000                *      9.34 %       ±8.20% ±10.90% ±14.20%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000            **     12.81 %       ±8.69% ±11.57% ±15.06%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000            **     14.09 %       ±8.45% ±11.24% ±14.64%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000             *      8.87 %       ±7.41%  ±9.86% ±12.83%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000          *      9.51 %       ±8.64% ±11.50% ±14.97%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000          *      8.01 %       ±7.93% ±10.55% ±13.74%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000             *      9.36 %       ±8.27% ±11.01% ±14.33%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000         **     11.51 %       ±8.56% ±11.39% ±14.82%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000          *     10.78 %       ±8.37% ±11.14% ±14.50%

The full-suite run showed no regressions in any other family; readable-read normal, tee normal, and pipeThrough passthrough spot runs also improve (~+6-18%). Verified with the WPT streams/compression/encoding suites, the full parallel webstream/whatwg test set, and a shutdown-ordering stress (abort mid-write, close with pending writes, sync-throwing and rejecting sinks, error propagation) whose event log is byte-identical to main.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. web streams labels Aug 8, 2026
Comment thread lib/internal/webstreams/util.js
Comment thread lib/internal/webstreams/util.js Outdated

@jasnell jasnell left a comment

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.

Couple of nits, otherwise LGTM

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.34%. Comparing base (4551732) to head (2929417).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65138      +/-   ##
==========================================
+ Coverage   90.30%   90.34%   +0.03%     
==========================================
  Files         751      751              
  Lines      250235   250330      +95     
  Branches    47305    47321      +16     
==========================================
+ Hits       225987   226171     +184     
+ Misses      15619    15557      -62     
+ Partials     8629     8602      -27     
Files with missing lines Coverage Δ
lib/internal/webstreams/readablestream.js 98.18% <100.00%> (+0.02%) ⬆️
lib/internal/webstreams/util.js 97.89% <100.00%> (+0.17%) ⬆️
lib/internal/webstreams/writablestream.js 99.52% <100.00%> (+<0.01%) ⬆️

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

@gurgunday gurgunday left a comment

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.

Specifically on 0ff1bfc, WDYT about this?

https://github.com/whatwg/streams/blob/29997074ffcfbe5b72a4451775398a54cef5b297/reference-implementation/lib/helpers/webidl.js#L45-L48

I think there was a reason Promise.resolve wasn't used here, and I'm not sure what the ramifications of using it might be

@mcollina

mcollina commented Aug 9, 2026

Copy link
Copy Markdown
Member Author

As long as WPT passes, I'm good if we can avoid a promise allocation.

That specifically seems non-observable behavior.

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 12, 2026
@mcollina
mcollina requested review from aduh95 and anonrig August 12, 2026 03:54
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 12, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Comment thread lib/internal/webstreams/readablestream.js Outdated
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (webstreams / pipe-to): https://github.com/nodejs/node/actions/runs/31826854651

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000              ***     25.57 %       ±7.64% ±10.07% ±12.92%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000           ***     29.57 %       ±7.55%  ±9.95% ±12.78%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000           ***     28.22 %       ±7.35%  ±9.69% ±12.44%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000           ***     19.26 %       ±7.14%  ±9.42% ±12.09%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000        ***     20.69 %       ±7.10%  ±9.36% ±12.01%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000        ***     20.21 %       ±6.83%  ±9.00% ±11.55%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000           ***     21.79 %       ±7.11%  ±9.37% ±12.03%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000        ***     20.12 %       ±7.17%  ±9.45% ±12.13%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***     19.61 %       ±7.01%  ±9.25% ±11.87%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 9 comparisons, you can thus
expect the following amount of false-positive results:
  0.45 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.09 false positives, when considering a   1% risk acceptance (**, ***),
  0.01 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 14, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 14, 2026
@nodejs-github-bot

This comment was marked as outdated.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@aduh95 aduh95 added author ready PRs that have at least one approval, no pending requests for changes, and a CI started. commit-queue Add this label to land a pull request using GitHub Actions. labels Aug 15, 2026
@nodejs-github-bot nodejs-github-bot added commit-queue-failed An error occurred while landing this pull request using GitHub Actions. and removed commit-queue Add this label to land a pull request using GitHub Actions. labels Aug 15, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator
Commit Queue failed
- Loading data for nodejs/node/pull/65138
✔  Done loading data for nodejs/node/pull/65138
----------------------------------- PR info ------------------------------------
Title      stream: cut promise churn in webstreams hot paths (#65138)
Author     Matteo Collina <matteo.collina@gmail.com> (@mcollina)
Branch     mcollina:webstream-perf-round12 -> nodejs:main
Labels     author ready, needs-ci, commit-queue, web streams
Commits    4
 - benchmark: apply highWaterMark in webstreams pipe-to
 - stream: cut promise churn in webstreams hot paths
 - stream: consolidate non-op algorithm callbacks
 - Update lib/internal/webstreams/readablestream.js
Committers 2
 - Matteo Collina <hello@matteocollina.com>
 - GitHub <noreply@github.com>
PR-URL: https://github.com/nodejs/node/pull/65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
------------------------------ Generated metadata ------------------------------
PR-URL: https://github.com/nodejs/node/pull/65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
--------------------------------------------------------------------------------
   ℹ  This PR was created on Sat, 08 Aug 2026 15:01:49 GMT
   ✔  Approvals: 4
   ✔  - James M Snell (@jasnell) (TSC): https://github.com/nodejs/node/pull/65138#pullrequestreview-4889126958
   ✔  - Gürgün Dayıoğlu (@gurgunday): https://github.com/nodejs/node/pull/65138#pullrequestreview-4941618229
   ✔  - Yagiz Nizipli (@anonrig) (TSC): https://github.com/nodejs/node/pull/65138#pullrequestreview-4937946571
   ✔  - Antoine du Hamel (@aduh95) (TSC): https://github.com/nodejs/node/pull/65138#pullrequestreview-4943235120
   ✔  Last GitHub CI successful
   ℹ  Last Full PR CI on 2026-08-15T04:53:41Z: https://ci.nodejs.org/job/node-test-pull-request/75855/
- Querying data for job/node-test-pull-request/75855/
✔  Build data downloaded
   ✔  Last Jenkins CI successful
--------------------------------------------------------------------------------
   ✔  No git cherry-pick in progress
   ✔  No git am in progress
   ✔  No git rebase in progress
--------------------------------------------------------------------------------
- Bringing origin/main up to date...
From https://github.com/nodejs/node
 * branch                  main       -> FETCH_HEAD
✔  origin/main is now up-to-date
- Downloading patch for 65138
From https://github.com/nodejs/node
 * branch                  refs/pull/65138/merge -> FETCH_HEAD
✔  Fetched commits as 9e23066b8af4..0c0d5beaa88d
--------------------------------------------------------------------------------
[main eaaf996401] benchmark: apply highWaterMark in webstreams pipe-to
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Aug 8 17:00:33 2026 +0200
 1 file changed, 4 insertions(+), 6 deletions(-)
Auto-merging lib/internal/webstreams/readablestream.js
[main 1d82311a65] stream: cut promise churn in webstreams hot paths
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Aug 8 17:00:48 2026 +0200
 3 files changed, 126 insertions(+), 25 deletions(-)
Auto-merging lib/internal/webstreams/readablestream.js
[main 6bb61acbad] stream: consolidate non-op algorithm callbacks
 Author: Matteo Collina <hello@matteocollina.com>
 Date: Sat Aug 8 19:32:35 2026 +0200
 3 files changed, 19 insertions(+), 25 deletions(-)
Auto-merging lib/internal/webstreams/readablestream.js
[main e67bb74dca] Update lib/internal/webstreams/readablestream.js
 Author: Matteo Collina <matteo.collina@gmail.com>
 Date: Fri Aug 14 10:03:20 2026 -0400
 1 file changed, 1 insertion(+), 1 deletion(-)
   ✔  Patches applied
There are 4 commits in the PR. Attempting autorebase.
(node:400) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
Rebasing (2/8)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
benchmark: apply highWaterMark in webstreams pipe-to

The highWaterMark values were passed as properties of the underlying
source and sink dictionaries, where they are ignored: a queuing
strategy's highWaterMark is read from the constructors' second argument.
Every configuration therefore measured the identical workload at the
default highWaterMark of 1, which also explains the historically high
run-to-run variance of this benchmark family.

Pass the strategies as the constructors' second argument and cover the
default (1) alongside buffered (1024, 4096) configurations.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>

[detached HEAD 5979438444] benchmark: apply highWaterMark in webstreams pipe-to
Author: Matteo Collina <hello@matteocollina.com>
Date: Sat Aug 8 17:00:33 2026 +0200
1 file changed, 4 insertions(+), 6 deletions(-)
Rebasing (3/8)
Rebasing (4/8)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
stream: cut promise churn in webstreams hot paths

Three related reductions on the per-chunk paths:

Wrap user sink.write and source.pull callbacks without coercing their
result into a promise. When the callback returns a non-thenable (the
common synchronous case), fulfillment is guaranteed and no then() lookup
is observable, so the fulfilled reaction is enqueued through a single
shared resolved promise at the exact microtask position the coerced
promise's reaction would have had, skipping the implicit async-wrapper
promise per chunk. Thenable results go through PromiseResolve(), which
matches the spec's "a promise resolved with" conversion (identity for
native promises).

Park pipeTo's pump on backpressure by installing a record that
duck-types the writer's lazily-materialized [[readyPromise]] record and
whose resolve function is the pump continuation itself. Backpressure
clearing then resumes the pump directly instead of materializing a fresh
promise record plus reaction per flip, and the pump no longer schedules
a microtask per batch. writableStreamUpdateBackpressure publishes the
new backpressure state before resolving the ready record so the pump
observes the updated value.

Replace queueMicrotask() on the pipeTo and tee chunk-forwarding paths
with a reaction on the shared resolved promise, which enqueues the
continuation at the same position without the per-call scheduling
overhead.

pipe-to improves by 8-14% across all benchmark configurations, with
readable-read and tee also improving in spot runs.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>

[detached HEAD 6536e7de9b] stream: cut promise churn in webstreams hot paths
Author: Matteo Collina <hello@matteocollina.com>
Date: Sat Aug 8 17:00:48 2026 +0200
3 files changed, 126 insertions(+), 25 deletions(-)
Rebasing (5/8)
Rebasing (6/8)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
stream: consolidate non-op algorithm callbacks

The start, pull, and write non-op algorithms are all raw callbacks with
an identical empty body now, so a single shared nonOpCallback replaces
nonOpStart, nonOpPull, and nonOpWrite.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>

[detached HEAD 2900aa12f7] stream: consolidate non-op algorithm callbacks
Author: Matteo Collina <hello@matteocollina.com>
Date: Sat Aug 8 19:32:35 2026 +0200
3 files changed, 19 insertions(+), 25 deletions(-)
Rebasing (7/8)
Rebasing (8/8)
Executing: git node land --amend --yes
--------------------------------- New Message ----------------------------------
Update lib/internal/webstreams/readablestream.js

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
PR-URL: #65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>

[detached HEAD f1d7d5568c] Update lib/internal/webstreams/readablestream.js
Author: Matteo Collina <matteo.collina@gmail.com>
Date: Fri Aug 14 10:03:20 2026 -0400
1 file changed, 1 insertion(+), 1 deletion(-)
Successfully rebased and updated refs/heads/main.

ℹ Add commit-queue-squash label to land the PR as one commit, or commit-queue-rebase to land as separate commits.

https://github.com/nodejs/node/actions/runs/31872533736

@aduh95 aduh95 added commit-queue Add this label to land a pull request using GitHub Actions. commit-queue-squash Add this label to instruct the Commit Queue to squash all the PR commits into the first one. and removed commit-queue-failed An error occurred while landing this pull request using GitHub Actions. commit-queue Add this label to land a pull request using GitHub Actions. commit-queue-squash Add this label to instruct the Commit Queue to squash all the PR commits into the first one. labels Aug 15, 2026
The highWaterMark values were passed as properties of the underlying
source and sink dictionaries, where they are ignored: a queuing
strategy's highWaterMark is read from the constructors' second argument.
Every configuration therefore measured the identical workload at the
default highWaterMark of 1, which also explains the historically high
run-to-run variance of this benchmark family.

Pass the strategies as the constructors' second argument and cover the
default (1) alongside buffered (1024, 4096) configurations.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: nodejs#65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
Three related reductions on the per-chunk paths:

Wrap user sink.write and source.pull callbacks without coercing their
result into a promise. When the callback returns a non-thenable (the
common synchronous case), fulfillment is guaranteed and no then() lookup
is observable, so the fulfilled reaction is enqueued through a single
shared resolved promise at the exact microtask position the coerced
promise's reaction would have had, skipping the implicit async-wrapper
promise per chunk. Thenable results go through PromiseResolve(), which
matches the spec's "a promise resolved with" conversion (identity for
native promises).

Park pipeTo's pump on backpressure by installing a record that
duck-types the writer's lazily-materialized [[readyPromise]] record and
whose resolve function is the pump continuation itself. Backpressure
clearing then resumes the pump directly instead of materializing a fresh
promise record plus reaction per flip, and the pump no longer schedules
a microtask per batch. writableStreamUpdateBackpressure publishes the
new backpressure state before resolving the ready record so the pump
observes the updated value.

Replace queueMicrotask() on the pipeTo and tee chunk-forwarding paths
with a reaction on the shared resolved promise, which enqueues the
continuation at the same position without the per-call scheduling
overhead.

pipe-to improves by 8-14% across all benchmark configurations, with
readable-read and tee also improving in spot runs.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: nodejs#65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
The start, pull, and write non-op algorithms are all raw callbacks with
an identical empty body now, so a single shared nonOpCallback replaces
nonOpStart, nonOpPull, and nonOpWrite.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: nodejs#65138
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
@aduh95
aduh95 force-pushed the webstream-perf-round12 branch from 0c0d5be to 2929417 Compare August 15, 2026 08:00
@aduh95

aduh95 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Landed in 4551732...2929417

@aduh95
aduh95 merged commit 2929417 into nodejs:main Aug 15, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author ready PRs that have at least one approval, no pending requests for changes, and a CI started. needs-ci PRs that need a full CI run. web streams

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants