Skip to content

stream: reduce webstreams encoding and iteration overhead - #65414

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

stream: reduce webstreams encoding and iteration overhead#65414
mcollina wants to merge 3 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.

The spec's [[backpressureChangePromise]] is only ever observed by the
transform source pull algorithm (settles when backpressure next becomes
true) and by a sink write arriving under backpressure (settles when it
next becomes false), both internal. Replace the promise record with
direct continuation delivery: a parked pull is completed by enqueueing
the readable controller's pull-fulfilled step on the shared resolved
promise, and a parked write by a cached per-stream continuation that
resolves the sink promise with the perform-transform promise, so
adoption reproduces the previous derived-chain settle depth exactly.
Each delivery lands at the same microtask position as the old record's
reaction.

transformStreamDefaultControllerPerformTransform now mirrors the
reference implementation's promiseCall().then(undefined, rejection
steps) directly instead of running an async wrapper pair per chunk: the
transformer.transform callback is wrapped raw, a non-thenable result
reuses the shared resolved promise, and a synchronous throw is
delivered through a rejected promise, keeping the error timing inside a
reaction.

A pipe-through benchmark is added since the suite had no
transform-throughput row.

pipeThrough passthrough improves by ~8% and a writer-driven transform
loop by ~14%; the pipe-to and read families are unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
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>
@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 71.05263% with 66 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.10%. Comparing base (449b950) to head (ffa229a).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/readablestream.js 25.30% 62 Missing ⚠️
lib/internal/webstreams/transformstream.js 96.33% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65414      +/-   ##
==========================================
- Coverage   90.11%   90.10%   -0.01%     
==========================================
  Files         752      752              
  Lines      252208   252358     +150     
  Branches    47447    47456       +9     
==========================================
+ Hits       227274   227397     +123     
- Misses      16219    16264      +45     
+ Partials     8715     8697      -18     
Files with missing lines Coverage Δ
lib/internal/webstreams/encoding.js 100.00% <100.00%> (ø)
lib/internal/webstreams/util.js 96.48% <100.00%> (-0.78%) ⬇️
lib/internal/webstreams/transformstream.js 93.97% <96.33%> (+0.13%) ⬆️
lib/internal/webstreams/readablestream.js 87.10% <25.30%> (-1.18%) ⬇️

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

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.

3 participants