Skip to content

stream: reduce webstreams encoding and iteration overhead - #65414

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-perf-round14
Open

stream: reduce webstreams encoding and iteration overhead#65414
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-perf-round14

Conversation

@mcollina

Copy link
Copy Markdown
Member

This PR is stacked on #65143 — please review the last two commits only.

Two independent optimizations for WHATWG streams:

TextEncoderStream: encode whole chunks natively. The encode-and-enqueue transform walked every code unit in JS, materializing a one-character string per index and building the output via string concatenation. The only state that crosses chunks is a trailing high (leading) surrogate, and TextEncoder.encode()'s USVString conversion already replaces interior lone surrogates with U+FFFD exactly like the spec loop. The transform now joins a pending high surrogate, holds back a new trailing one, and encodes the rest in a single native call. Verified byte-identical to the previous algorithm over 200k randomized surrogate-heavy chunk sequences, plus the full WPT encoding suite. The streaming decode path also stops allocating a { stream: true } options object per chunk.

ReadableStream.from(): drop the async pull machinery. The pull algorithm was an async function awaiting iterator.next() and then the value — an async frame, two await wrappers, and a controller-side reaction per chunk. It is now callback-style with per-stream cached reaction steps, delivering completion directly to the controller's cached pull reactions (the parked-algorithm-result contract from #65143). Thenable adoption is preserved, including the observable .then lookup on plain object values. The iterator's next method is now looked up once at setup, matching the spec's GetIteratorDirect.

Benchmark results (benchmark/compare.js --runs 10, new rows added since the suite covered neither path):

                                                                confidence improvement accuracy (*)    (**)   (***)
webstreams/encoding-streams.js len=1024 kind='encode' n=100000        ***    546.04 %      ±16.30% ±23.40% ±34.38%
webstreams/encoding-streams.js len=16 kind='encode' n=100000          ***     19.93 %       ±4.31%  ±5.96%  ±8.26%
webstreams/from.js kind='sync' n=1000000                              ***     27.39 %       ±2.15%  ±2.96%  ±4.06%
webstreams/from.js kind='async' n=1000000                             ***     23.30 %       ±2.74%  ±3.76%  ±5.13%

All other webstreams rows (pipe-to ×9, pipe-through, read/read-buffered, async-iterator, tee, creation, js_transfer, decode) are unchanged. Full WPT streams + encoding suites and the parallel webstreams/whatwg test batches pass.

@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 20, 2026
@XinGOfCloude18

Copy link
Copy Markdown

Review requested:

  • @nodejs/performance

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 41.23711% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.09%. Comparing base (9677349) to head (c8e5339).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/readablestream.js 24.00% 57 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65414      +/-   ##
==========================================
- Coverage   90.12%   90.09%   -0.03%     
==========================================
  Files         752      752              
  Lines      252317   252380      +63     
  Branches    47435    47437       +2     
==========================================
- Hits       227401   227389      -12     
- Misses      16209    16311     +102     
+ Partials     8707     8680      -27     
Files with missing lines Coverage Δ
lib/internal/webstreams/encoding.js 100.00% <100.00%> (ø)
lib/internal/webstreams/readablestream.js 87.20% <24.00%> (-1.08%) ⬇️

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

// operation and takes responsibility for delivering the fulfilled (or
// rejected) continuation itself later, instead of settling a promise
// (see the transform stream source pull algorithm).
const kParkedAlgorithmResult = { __proto__: null };

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.

Can this be a Symbol?

@mcollina
mcollina force-pushed the webstream-perf-round14 branch 2 times, most recently from fde1b0b to 4a02249 Compare August 20, 2026 17:29

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

lgtm

The encode-and-enqueue transform walked the chunk code unit by code
unit, materializing a single-character string per index and building
the output with string concatenation. The only state that crosses
chunks is a trailing high (leading) surrogate, and TextEncoder.encode's
USVString conversion already replaces every interior lone surrogate
with U+FFFD, which is exactly what the spec loop produces. Join a
pending high surrogate with the incoming chunk, hold back a new
trailing high surrogate, and encode the rest in a single native call.

The streaming decode path also reuses a single options object instead
of allocating { stream: true } per chunk.

An encoding-streams benchmark is added since the suite had no
TextEncoderStream/TextDecoderStream row. Encoding improves by ~546%
with 1KB string chunks and ~20% with 16-character chunks; decode is
unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
The pull algorithm was an async function that awaited iterator.next()
and then the produced value, costing an async-function frame plus two
await wrappers and a controller-side reaction per chunk. Rewrite it
callback-style: the next() result is adopted exactly like the previous
awaits (including the observable .then lookup on plain object values),
the reaction steps are created once per stream, and completion is
delivered straight to the controller's cached pull reactions using the
parked-algorithm-result contract. The iterator's next method is also
looked up once at setup, per the spec's GetIteratorDirect.

A from benchmark is added since the suite had no ReadableStream.from
row. Iterating a stream built from a sync generator improves by ~27%
and from an async generator by ~23%; all other rows are unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round14 branch from 4a02249 to c8e5339 Compare August 21, 2026 01:08
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.

5 participants