Skip to content

stream: share webstreams async iterator methods - #66392

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

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

Conversation

@mcollina

@mcollina mcollina commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Round 20 of the webstreams performance work (follows #66230). It targets for await over a short-lived stream, such as iterating a response body once per request.

Async iterator methods on a shared prototype

ReadableStream.prototype.values() built every iterator from an object literal holding next(), return() and a computed [Symbol.asyncIterator]() method, plus closures for the next and return steps. A literal with a computed symbol-key method goes through the runtime on every evaluation, the same cost #66154 removed from pipeTo and tee. That cost close to a microsecond per iterator.

next() and return() now live on a shared ReadableStreamAsyncIterator prototype, which is how WebIDL defines async iterators. Each iterator holds only a private reference to its state, and that state doubles as the iterator's read request. The prototype extends %AsyncIteratorPrototype% and has exactly the next and return properties WPT checks for. This makes the placeholder AsyncIterator object in internal/webstreams/util.js unnecessary. The return steps are no longer an async function either: the cancel path chains the result onto the cancel promise, which settles one microtask later exactly as await did. Microtask timing is unchanged, including the extra hop before the first read.

Observable differences

Both follow WebIDL and match browsers:

  • iterators no longer carry own next/return properties (Object.keys(stream.values()) was ['next', 'return'], now []);
  • next() and return() return a promise rejected with ERR_INVALID_THIS when called on something that is not a ReadableStream async iterator, for example after const { next } = iterator.

test/parallel/test-whatwg-readablestream-async-iterator-shape.js covers both.

Benchmark

benchmark/webstreams/lifecycle.js gains an async-iterator kind (create a stream, read 4 chunks with for await).

node benchmark/compare.js --runs 20 (lifecycle, readable-async-iterator, readable-read):

                                                             confidence improvement accuracy (*)    (**)   (***)
webstreams/lifecycle.js kind='async-iterator' n=50000               ***     16.22 %       ±4.51%  ±6.10%  ±8.14%
webstreams/lifecycle.js kind='pipe-through' n=50000                         -1.97 %       ±4.39%  ±5.88%  ±7.73%
webstreams/lifecycle.js kind='pipe-to' n=50000                              -3.13 %       ±8.35% ±11.19% ±14.72%
webstreams/lifecycle.js kind='readable' n=50000                             -0.54 %       ±2.96%  ±3.97%  ±5.23%
webstreams/readable-async-iterator.js type='bytes' n=100000                 -2.65 %       ±3.37%  ±4.51%  ±5.94%
webstreams/readable-async-iterator.js type='normal' n=100000                 1.49 %       ±5.42%  ±7.26%  ±9.56%
webstreams/readable-read.js type='byob' n=100000                            -0.91 %       ±2.00%  ±2.69%  ±3.57%
webstreams/readable-read.js type='normal' n=100000                           5.54 %       ±6.24%  ±8.36% ±10.99%

The long-running iterator rows are flat as expected, since the saving is per iterator, not per chunk. With a source that enqueues its chunks in start(), iterator creation is a larger share of the work and a local harness measured +70 %.

A 26-scenario microtask-ordering stress for async iteration logs identically against main. It covers concurrent next() calls, return() with a read pending, slow, rejecting and throwing cancel(), break, errors, preventCancel and byte streams. WPT streams and the webstreams parallel batch are green.


AI generated, humanly reviewed.

@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 Issues and PRs related to the Web Streams API. labels Sep 29, 2026
@mcollina
mcollina marked this pull request as ready for review September 29, 2026 07:52
ReadableStream.prototype.values() built each iterator from an object
literal with a computed symbol-key method plus five closures. Such a
literal is rebuilt through the runtime on every evaluation, costing
close to a microsecond per iterator, which dominates iterating a
short-lived stream.

Move next() and return() to a shared ReadableStreamAsyncIterator
prototype, as for any WebIDL async iterator, and keep the per-iterator
state in its read request. The prototype chain and property shape are
the ones WPT checks; the placeholder AsyncIterator object in util.js is
no longer needed. As in WebIDL, next() and return() now reject when
called on something that is not a ReadableStream async iterator, and
iterators no longer carry own next/return properties.

Add an async-iterator kind to benchmark/webstreams/lifecycle.js.

webstreams/lifecycle.js kind='async-iterator'   ***  +16.22%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round20 branch from b436b55 to 6d9a4f3 Compare September 29, 2026 07:55
@codecov

codecov Bot commented Sep 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.21965% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.37%. Comparing base (6866129) to head (6d9a4f3).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/readablestream.js 94.21% 9 Missing and 1 partial ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #66392   +/-   ##
=======================================
  Coverage   90.36%   90.37%           
=======================================
  Files         792      792           
  Lines      275498   275514   +16     
  Branches    52798    52810   +12     
=======================================
+ Hits       248947   248982   +35     
+ Misses      16976    16947   -29     
- Partials     9575     9585   +10     
Files with missing lines Coverage Δ
lib/internal/webstreams/util.js 97.07% <ø> (-0.05%) ⬇️
lib/internal/webstreams/readablestream.js 98.08% <94.21%> (+0.01%) ⬆️

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

@Renegade334 Renegade334 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.

This makes the placeholder AsyncIterator object in internal/webstreams/util.js unnecessary.

🙏 this was one of our uglier WPT hacks!

// rather than being created per iterator. Neither method may be an async
// function: `await` goes through Promise.prototype.then, which the streams
// WPTs patch.
class ReadableStreamAsyncIterator {

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.

Pedantically, this prototype should have a toStringTag of 'ReadableStream AsyncIterator'.

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.

3 participants