Skip to content

stream: speed up WHATWG web streams - #65273

Open
anonrig wants to merge 2 commits into
nodejs:mainfrom
anonrig:stream-speed-up-webstreams
Open

stream: speed up WHATWG web streams#65273
anonrig wants to merge 2 commits into
nodejs:mainfrom
anonrig:stream-speed-up-webstreams

Conversation

@anonrig

@anonrig anonrig commented Aug 13, 2026

Copy link
Copy Markdown
Member

Behavior-preserving performance work on node:stream/web. Spec
orchestration and brand checks stay in JS; the per-chunk data plane
drops Promise/microtask churn for the common sync pull/write case
and gets a small native helper on the hot path.

  • Skip async wrappers on sync source/sink algorithms.
    createPromiseCallback* now calls the user function and returns
    the raw result. Non-thenable results settle via queueMicrotask
    (same position as Promise.resolve().then) instead of allocating
    a Promise per pull/write/start.
  • pipeTo fills a default readable queue from sync pulls and
    still batches already-queued chunks into the destination. Further
    spec pull-fulfillment (tee, WPT) stays one pull per microtask.
  • pipeTo's shared write tracker completes sync sink writes in
    the fulfillment turn. Regular writer.write() keeps the spec
    one-completion-per-microtask order.
  • Native internalBinding('webstreams'): Fast API
    isNonThenable() on every pull/write/start result, and
    cloneAsUint8Array() as a single memcpy for byte-stream / tee
    clones.
  • Cheaper empty construction. new ReadableStream() /
    new WritableStream() skip validateObject on the shared empty
    sentinels. The writable AbortController is created lazily and
    materialized on abort(), so controller.signal observed after
    abort is still aborted with that reason.

Public constructors, methods, and WHATWG Streams behavior
(backpressure, BYOB, pipeTo, tee, errors, transfer) are unchanged.

Benchmarks

benchmark/compare.js --runs 10 of the in-repo webstreams/ suite
on the same machine, same out/Release/node family (pre-change
binary vs this tree). Rates are ops/sec.

Hot-path geometric mean of new/old across all configs of
pipe-to.js, readable-read.js, readable-read-buffered.js,
creation.js, readable-async-iterator.js, and tee.js:
1.94x (32 configs). Full suite including js_transfer.js:
1.84x (35 configs). No config has mean(new)/mean(old) < 1.0
(min 1.03 on js_transfer ReadableStream).

script config old new ratio
pipe-to HWM 512/512 2.15M 4.83M 2.25
pipe-to HWM 4096/4096 2.17M 4.85M 2.24
tee normal 1.12M 2.56M 2.29
readable-async-iterator normal 3.77M 8.59M 2.28
creation WritableStream 0.67M 1.37M 2.05
readable-read-buffered bufferSize=1000 5.61M 12.17M 2.17
readable-read normal 3.99M 5.37M 1.35
creation ReadableStream 1.35M 2.01M 1.49
js_transfer ReadableStream 59.0k 61.0k 1.03

Tests

  • test/wpt/test-streams.js
  • test/parallel/test-whatwg-readable*, writable*, transform*,
    webstreams*, test-webstreams*, test-global-webstreams.js
  • new test/parallel/test-whatwg-webstreams-hotpath.js (public
    read() / pipeTo, native helpers, abort-before-signal)
  • abort-before-signal case in test-whatwg-writablestream.js

AI assistance

This change was developed with assistance from Grok. I reviewed,
tested, and take responsibility for the submitted code.

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/gyp

@nodejs-github-bot nodejs-github-bot added lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. labels Aug 13, 2026
Avoid per-chunk async wrappers for sync pull/write/start, fill
default readable queues in pipeTo, and complete pipeTo writes
without one microtask per chunk. Add a native webstreams binding
with a Fast API isNonThenable check on the data plane and a
memcpy clone for byte views.

Empty stream construction skips redundant validation and lazily
creates the writable AbortController, materializing it on abort()
so controller.signal still reflects the abort reason.

Assisted-by: Grok
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Declare isNonThenable and cloneAsUint8Array on the new webstreams
binding and register it in InternalBindingMap.

Assisted-by: Grok
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
@anonrig
anonrig force-pushed the stream-speed-up-webstreams branch from 6a3f89b to c796760 Compare August 13, 2026 23:23
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.32%. Comparing base (e1cdcec) to head (c796760).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
src/node_webstreams.cc 78.72% 6 Missing and 4 partials ⚠️
lib/internal/webstreams/readablestream.js 97.36% 2 Missing ⚠️
lib/internal/webstreams/writablestream.js 98.80% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65273      +/-   ##
==========================================
+ Coverage   90.31%   90.32%   +0.01%     
==========================================
  Files         751      752       +1     
  Lines      249840   250072     +232     
  Branches    47180    47240      +60     
==========================================
+ Hits       225645   225882     +237     
+ Misses      15587    15559      -28     
- Partials     8608     8631      +23     
Files with missing lines Coverage Δ
lib/internal/webstreams/transformstream.js 98.58% <100.00%> (+0.01%) ⬆️
lib/internal/webstreams/util.js 97.92% <100.00%> (+0.20%) ⬆️
src/node_binding.cc 82.42% <ø> (ø)
src/node_external_reference.h 100.00% <ø> (ø)
lib/internal/webstreams/writablestream.js 99.47% <98.80%> (-0.05%) ⬇️
lib/internal/webstreams/readablestream.js 98.14% <97.36%> (-0.02%) ⬇️
src/node_webstreams.cc 78.72% <78.72%> (ø)

... and 41 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 empty-argument constructor is the creation.js / `new
// ReadableStream()` hot path: skip validateObject and strategy/source
// extraction when both arguments are the shared default sentinel.
if (source === kEmptyObject && strategy === kEmptyObject) {

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.

In this case, we ought to be able to fully elide the creation of the standard controller entirely. The result here is a completely useless stream whose reads will never resolve. It's a degenerate case that is likely quite unlikely, but if it happens, we may as not waste the additional allocations.

(error) => readableStreamDefaultControllerError(controller, error);
}
const result = controller[kState].pullAlgorithm(controller);
if (isNonThenable(result)) {

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 be clearly documented as a non-standard and potentially breaking behavior. The pull algorithm is defined by the spec as "a promise-returning algorithm". The elimination of the microtask continuation likely makes this semver-major.

}
const result = controller[kState].pullAlgorithm(controller);
if (isNonThenable(result)) {
queueMicrotask(controller[kState].pullFulfilled);

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.

Care should be taken here. If the pullFulfilled happens to throw for whatever reason, the error is going to be propagated differently than in the promise case below. Not a significant issue since pullfulfilled really shouldn't throw but the case needs to be carefully evaluated.

Comment thread lib/internal/webstreams/util.js
validateFunction(fn, name);
return async () => FunctionPrototypeCall(fn, thisArg);
return () => {
try {

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.

Promise.try / PromiseTry?

function nonOpCancel() {}

async function nonOpWrite() {}
function nonOpWrite() {}

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.

Just have a single no-op function. There's no reason to create multiple functions that do nothing.

Comment thread src/node_webstreams.cc
static bool IsNonThenableValue(Local<Value> value) {
return value->IsNullOrUndefined() ||
(!value->IsObject() && !value->IsFunction());
}

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 understand why this needs to be a C++ function. These are all checks that can be done cheaply and easily in JS. Does this handle Proxy?

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.

Proxy is handled fine, but would add a test.

@jasnell jasnell added the semver-major PRs that contain breaking changes and should be released in the next major version. label Aug 14, 2026
@jasnell

jasnell commented Aug 14, 2026

Copy link
Copy Markdown
Member

Defensively marking this semver-major. If you can show that the optimization does not change observable behavior, that can be dropped, but the change in microtask timing from one pull to the next is likely observable.

state.pulling = true;
const before = state.queue.length;
const result = state.pullAlgorithm(controller);
if (isNonThenable(result)) {

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.

The name here is ever-so-slightly-misleading. An object with no then property is technically not a "thenable" ... Pedantic, yes.

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

Labels

lib / src Issues and PRs related to general changes in the lib or src directory. needs-ci PRs that need a full CI run. semver-major PRs that contain breaking changes and should be released in the next major version.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants