Skip to content

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

Open
mcollina wants to merge 3 commits into
nodejs:mainfrom
mcollina:webstream-perf-round12
Open

stream: cut promise churn in webstreams hot paths#65138
mcollina wants to merge 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.

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>
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>
@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.30%. Comparing base (347e266) to head (e30f373).
⚠️ Report is 8 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff            @@
##             main   #65138    +/-   ##
========================================
  Coverage   90.30%   90.30%            
========================================
  Files         759      759            
  Lines      248294   248415   +121     
  Branches    46860    46871    +11     
========================================
+ Hits       224220   224334   +114     
+ Misses      15516    15503    -13     
- Partials     8558     8578    +20     
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 37 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.

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>
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants