Repository navigation
Conversation
mcollina
requested review from
MattiasBuelens and
anonrig
and removed request for
MattiasBuelens
October 10, 2026 12:07
mcollina
marked this pull request as ready for review
October 10, 2026 12:08
A source without pull() has nothing observable left to do in its post-start step, since the started flag only gates calls into the pull algorithm. Set the flag right away instead of from a microtask, so a push-style ReadableStream allocates neither the closure nor the task and is not kept alive until the next microtask checkpoint. pipeTo and tee hold the only references to their reader and writer, so their [[closedPromise]] records are never observed as promises. Install the watchers as the records themselves, as pipeTo's ready hook already does, instead of materializing a promise plus reaction per side, and hand the erroring/release probes one shared pending promise. The tee's cancel promise is likewise materialized by the first branch cancel. Constructing a TransformStream allocated five closures for the sink and source algorithms of its two sides, and each side adopted the start promise through a wrapper promise plus a thenable job, so a stream without a start() left about three kilobytes pending in the microtask queue until the started steps ran. Per-stream creation bursts spend most of their time copying that graph through the scavenger. The sink and source algorithms are now shared functions that reach the transform stream through a field on their controller state (the controller is passed to the close, abort and cancel algorithms for that), and the post-start steps of both sides are delivered by one reaction chain on a shared promise, taking the same microtask hops as the spec's start promise adoption: three after construction for a non-thenable start result, two after the adopting promise settles for a thenable one. The readable and writable transfer state records are materialized on first transfer instead of per stream. Microtask ordering is unchanged throughout: each hook and start step runs at the position the promise reaction would have had. node benchmark/compare.js --runs 20 over benchmark/webstreams (46 rows, all others within the confidence interval): webstreams/creation.js kind='ReadableStream' *** +172.40% webstreams/creation.js kind='TransformStream' *** +66.74% webstreams/creation.js kind='ReadableStream.tee' *** +31.36% webstreams/creation.js kind='ReadableStreamBYOBReader' *** +15.66% webstreams/creation.js kind='ReadableStreamDefaultReader' *** +14.53% webstreams/lifecycle.js kind='pipe-through' *** +13.89% webstreams/lifecycle.js kind='pipe-to' (40 runs) ** +10.98% Signed-off-by: Matteo Collina <hello@matteocollina.com>
mcollina
force-pushed
the
webstream-perf-round22
branch
from
October 10, 2026 12:08
b4189e2 to
334b151
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #66643 +/- ##
==========================================
+ Coverage 90.43% 90.46% +0.02%
==========================================
Files 791 791
Lines 276605 276996 +391
Branches 53117 53230 +113
==========================================
+ Hits 250155 250577 +422
+ Misses 16866 16836 -30
+ Partials 9584 9583 -1
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rounds 21 and 22 of the webstreams performance work in one commit (supersedes #66424, which this includes). It targets what a stream costs before its first chunk and after its last one: construction of
ReadableStream,WritableStreamandTransformStream,tee()andpipeTo()setup and shutdown.Idle start steps and closed watchers (round 21)
No post-start microtask for sources without
pull()setupReadableStreamDefaultControllerqueued a microtask after everystart()to flip the controller'sstartedflag and callpull()if needed. That flag only gates calls into the pull algorithm, so for a source withoutpull()(push-style sources that enqueue fromstart(), ornew ReadableStream()) the step has nothing observable left to do. The flag is now set synchronously in that case and no closure or task is allocated. Sources withpull()keep the microtask, at the same position. Byte streams get the same treatment.This also stops a burst of short-lived streams from being kept alive until the next microtask checkpoint, which is where most of the time in the creation benchmark went.
Closed watchers as records instead of promises
pipeTo()holds the only references to its reader and writer, andtee()to its reader, so their[[closedPromise]]records are never observed as promises. Each pipe still materialized both records plus a reaction to watch for close and error, and each tee one record plus a reaction. The watchers are now installed as the records themselves (ClosedPromiseHook), the way the pipe's ready hook already worked: the settle sites resolve or reject the record where they would have settled the promise, and the hook enqueues the watcher at the microtask position its reaction would have had. The erroring and release paths probe the record'spromise, so every hook carries one shared forever-pending promise, whichsetPromiseHandled()skips.The tee's cancel promise is materialized by the first branch cancel only.
TransformStream construction (round 22)
Profiling the creation benchmarks showed they are dominated by the scavenger rather than by JavaScript: a start step pending in the microtask queue keeps the whole stream graph live across young-generation collections, so the cost of a creation burst follows the bytes each stream leaves pending and retained (a
TransformStreamwent from 6.1 µs to 0.9 µs per construction with a 128 MB semi-space). ATransformStreamwithoutstart()left about three kilobytes behind: five closures for the sink and source algorithms of its two sides, and per side a wrapper promise, a thenable job, two reactions and two closures to adopt the start promise.Shared sink and source algorithms
The five algorithms are now module-level functions. They reach the transform stream through a
transformStreamfield on the state of the readable and writable controller they serve, and the close, abort and cancel algorithms receive the controller as a trailing argument for that (the wrappers around user sinks and sources ignore it).One start delivery for both sides
Per spec the start promise is resolved with the transformer's start result at the end of construction, and each side then adopts it through a wrapper promise.
transformStreamStart()takes the same microtask hops with reactions on one shared promise: three after construction for a non-thenable start result, two after the adopting promise settles for a thenable one, with the rejection path erroring both sides in the same order as before. The readable and writable controllers expose their post-start steps for that (readableStreamDefaultControllerStarted(),writableStreamDefaultControllerStarted()/StartFailed()), and akDeferredStartstart result tells the setup to leave the step to the caller. User-facing streams keep the existing wrapper.Lazy transfer state
The
transferrecord ofReadableStreamandWritableStreamstate is materialized on first transfer instead of per stream.Tests
test/parallel/test-whatwg-readablestream-tee-cancel-settle.jscovers the tee cancel promise settling before and after materialization, for default and byte streams. A 53-scenario microtask-ordering stress (pull, push, iterators, tee, every pipeTo shutdown path, transform backpressure,ReadableStream.from, writers) and a 27-scenario start-timing probe (nostart(),start()returningundefined, a resolved, pending, late-resolved, late-rejected or rejected promise, a thenable object, a sync throw, close/cancel/abort/terminate/enqueue/error during start, pipe-through, backpressure, writable and readable starts, transfer) logs identical microtask ticks againstmain. WPT streams/encoding/compression and the webstreams parallel batch are green.Benchmark
node benchmark/compare.js --runs 20overbenchmark/webstreams, round 22 on top of round 21 (the round-21 numbers againstmainare in #66424; combined, creation ReadableStream is +172 %, TransformStream +67 %, tee +31 %, readers +15 %, lifecycle pipe-through +14 %, pipe-to +11 %):The
readable-async-iterator.js type='bytes'row (untouched code) re-run directly with 30 samples:The per-chunk rows are flat as expected: the savings are per stream. A local harness that constructs a
TransformStreammeasured +67 %.AI generated, humanly reviewed.