WebStreamsを10倍高速化するためにラルフ・ウィガム方式を採用
Vercelのチームは、Next.jsのサーバーレンダリングにおけるパフォーマンスボトルネックをWebStreamsのオーバーヘッドと特定し、AIを活用したテスト駆動・ベンチマーク駆動の再実装により10倍高速化を達成し、その成果がNode.js本体へのアップストリームPRを通じて広く貢献している。
キーポイント
パフォーマンス問題の特定
Next.jsサーバーレンダリングのプロファイリングで、WebStreams自体のPromiseチェーン、チャンクごとのオブジェクト割り当て、マイクロタスクキュー処理が主要なオーバーヘッド要因であることが判明した。
新旧ストリーミングAPIの性能差
WHATWG Streams API(WebStreams)はNode.jsの従来のストリーミングAPI(stream.Readable等)と比較して、1KBチャンクのpipeThroughで約12倍(630 MB/s vs 7,900 MB/s)の性能差があり、その差は主にPromiseとオブジェクト割り当てのオーバーヘッドによる。
AI駆動の再実装アプローチ
WebStreamsの包括的なテストスイートを活用し、AIベースの再実装を純粋にテスト駆動・ベンチマーク駆動で行い、最適化を進めた。
成果の広範な影響
このパフォーマンス改善作業は、Matteo CollinaによるNode.js本体へのアップストリームPRを通じて、フレームワークを超えて広くサーバーサイド開発に貢献している。
高速化の核心:ノードストリームの直接利用
fast-webstreamsは、内部でNode.jsストリームを使用し、同じAPIと仕様準拠を保ちながら、高速パスを実装することでオーバーヘッドを削減している。
React Flightパターンでの大幅な性能向上
React Server Componentsで使用されるバイトストリームパターンにおいて、fast-webstreamsはネイティブWebStreamsの約14.6倍高速で、サーバーサイドレンダリングのパフォーマンスを大幅に向上させる。
フェッチレスポンスの制限と対応
サーバーサイドでは多くのストリームがfetch()から始まるが、レスポンスボディはNode.jsのHTTPレイヤーが所有するネイティブバイトストリームであり、置き換えができないという制約がある。
影響分析・編集コメントを表示
影響分析
この記事は、特定のフレームワーク(Next.js)の最適化作業が、基盤技術(Node.js)自体の改善につながり、業界全体のパフォーマンス向上に寄与する好例を示している。AIをエンジニアリングプロセス(テスト・ベンチマーク駆動開発)に活用した点も、実践的なAI応用例として注目される。
編集コメント
技術的深掘りがしっかりしており、単なる成果報告ではなく、問題発見から解決、業界貢献までのストーリーが明確。AIの活用方法が「再実装の支援」という実践的で地に足のついた事例として参考になる。
今年初めにNext.jsのサーバーレンダリングのプロファイリングを開始したとき、フレームグラフに一つの要素が繰り返し現れました:WebStreamsです。それらの中で実行されるアプリケーションコードではなく、ストリームそのものです。Promiseチェーン、チャンクごとのオブジェクト割り当て、マイクロタスクキューのホップです。Theo Browneのサーバーレンダリングベンチマークがフレームワークのオーバーヘッドにどれだけの計算時間が費やされているかを明らかにした後、私たちはその時間が実際にどこで消費されているかを調べ始めました。その多くはストリームの中にありました。
WebStreamsには信じられないほど完全なテストスイートがあり、それが純粋にテスト駆動およびベンチマーク駆動の方法でAIベースの再実装を行うのに最適な候補となっています。この記事は、私たちが行ったパフォーマンス作業、学んだこと、そしてこの作業がMatteo CollinaのアップストリームPRを通じてすでにNode.js自体に取り込まれつつある方法についてです。
問題
Node.jsには2つのストリーミングAPIがあります。古いもの(stream.Readable、stream.Writable、stream.Transform)は10年以上前から存在し、高度に最適化されています。データはC++内部を通過します。背圧はブール値です。パイピングは単一の関数呼び出しです。
新しいものはWHATWG Streams APIです:ReadableStream、WritableStream、TransformStream。これはウェブ標準です。これはfetch()のレスポンスボディ、CompressionStream、TextDecoderStream、そしてますますNext.jsやReactのようなフレームワークでのサーバーサイドレンダリングを動かしています。
ウェブ標準は収束すべき正しいAPIです。しかしサーバー上では、必要以上に遅いのです。
その理由を理解するために、Node.jsでネイティブWebStreamに対してreader.read()を呼び出したときに何が起こるかを考えてみてください。データがすでにバッファにある場合でも:
ReadableStreamDefaultReadRequestオブジェクトが3つのコールバックスロットで割り当てられる
リクエストがストリームの内部キューにエンキューされる
新しいPromiseが割り当てられて返される
解決はマイクロタスクキューを通過する
これは、すでに存在していたデータを返すために4つの割り当てとマイクロタスクホップです。さて、これをレンダリングパイプラインのすべての変換を流れるすべてのチャンクで乗算してみてください。
あるいはpipeTo()を考えてみてください。各チャンクは完全なPromiseチェーンを通過します:読み取り、書き込み、背圧チェック、繰り返し。{value, done}結果オブジェクトが読み取りごとに割り当てられます。エラー伝播は追加のPromiseブランチを作成します。
これらは間違っていません。これらの保証は、ストリームがセキュリティ境界を越え、キャンセルセマンティクスが厳密である必要があり、パイプの両端を制御できないブラウザでは重要です。しかしサーバー上では、React Server Componentsを1KBチャンクで3つの変換を通してパイピングしているとき、コストは積み上がります。
私たちはネイティブWebStreamのpipeThroughを1KBチャンクで630 MB/sでベンチマークしました。同じパススルー変換でのNode.js pipeline():〜7,900 MB/s。これは12倍の差であり、その違いはほとんど完全にPromiseとオブジェクト割り当てのオーバーヘッドです。
私たちが構築したもの
私たちはfast-webstreamsというライブラリに取り組んでいます。これはWHATWGのReadableStream、WritableStream、TransformStream APIを内部でNode.jsストリームによってバックアップして実装しています。同じAPI、同じエラー伝播、同じ仕様準拠です。一般的なケースではオーバーヘッドが除去されています。
核となるアイデアは、実際に行っていることに応じて異なる高速パスを通じて操作をルーティングすることです:
高速ストリーム間でパイプするとき:ゼロPromise
これが最大の成果です。高速ストリーム間でpipeThroughとpipeToをチェーンするとき、ライブラリはすぐにパイピングを開始しません。代わりに、アップストリームリンクを記録します:
source → transform1 → transform2 → ...
チェーンの最後でpipeTo()が呼び出されると、アップストリームを辿り、基礎となるNode.jsストリームオブジェクトを収集し、単一のpipeline()呼び出しを発行します。一つの関数呼び出し。チャンクごとのゼロPromise。データはNodeの最適化されたC++パスを流れます。
結果:〜6,200 MB/s。これはネイティブWebStreamsより〜10倍高速で、生のNode.js pipelineパフォーマンスに近いです。
チェーン内のいずれかのストリームが高速ストリームでない場合(例えばネイティブのCompressionStream)、ライブラリはネイティブのpipeThroughまたは仕様準拠のpipeTo実装にフォールバックします。
チャンクごとに読み取るとき:同期的解決
reader.read()を呼び出すとき、ライブラリはnodeReadable.read()を同期的に試みます。データがあれば、Promise.resolve({value, done})が得られます。イベントループの往復はありません。リクエストオブジェクトの割り当てはありません。バッファが空のときだけリスナーを登録し、保留中のPromiseを返します。
結果:〜12,400 MB/s、またはネイティブより3.7倍高速です。
React Flightパターン:差が最大のところ
これはNext.jsにとって最も重要なものです。React Server Componentsは特定のバイトストリームパターンを使用します:type: 'bytes'でReadableStreamを作成し、start()でコントローラーをキャプチャし、レンダリングが生成するときに外部からチャンクをエンキューします。
ネイティブWebStreams:〜110 MB/s。fast-webstreams:〜1,600 MB/s。これは本番サーバーレンダリングで使用される正確なパターンで14.6倍高速です。
速度は、バイトストリーム用にNode.jsのReadableを置き換えるために書いた最小限の配列ベースのバッファであるLiteReadableから来ています。これはEventEmitterの代わりに直接コールバックディスパッチを使用し、プルベースの需要とBYOBリーダーをサポートし、構築ごとに約5マイクロ秒少ないコストです。これはReact Flightがリクエストごとに数百のバイトストリームを作成するときに重要です。
Fetchレスポンスボディ:自分で構築しないストリーム
上記の例はすべてnew ReadableStream(...)で始まります。しかしサーバー上では、ほとんどのストリームはそのようには始まりません。それらはfetch()から始まります。レスポンスボディはNode.jsのHTTPレイヤーが所有するネイティブバイトストリームです。これを入れ替えることはできません。
これはサーバーサイドレンダリングで一般的なパターンです:アップストリームサービスからデータをフェッチし、レスポンスを1つ以上の変換を通してパイプし、結果をクライアントに転送します。
ネイティブWebStreamsでは、このチェーンの各ホップがチャンクごとの完全なPromiseコストを支払います。3つの変換はチャンクごとに約6-9のPromiseを意味します。1KBチャンクでは、〜260 MB/sになります。
ライブラリは遅延解決を通じてこれを処理します。patchGlobalWebStreams()がアクティブなとき、Response.prototype.bodyはネイティブバイトストリームをラップする軽量な高速シェルを返します。pipeThrough()を呼び出しても、すぐにパイピングを開始しません。単にリンクを記録するだけです。最後でpipeTo()またはgetReader()が呼び出されたときだけ、ライブラリは完全なチェーンを解決します:ネイティブリーダーから変換ホップ用のNode.js pipeline()への単一のブリッジを作成し、その後バッファリングされた出力から同期的に読み取りを提供します。
コストモデル:ネイティブ境界でデータを取り込むための1つのPromise。変換チェーンを通じてのゼロPromise。出力での同期読み取り。
結果:〜830 MB/s、または3変換フェッチパターンでネイティブより3.2倍高速です。変換なしの単純なレスポンス転送では、2.0倍高速です(850 vs 430 MB/s)。
ベンチマーク
すべての数値はNode.js v22での1KBチャンクでのMB/sのスループットです。高いほど良いです。
コア操作
操作
Node.jsストリーム
fast
ネイティブ
fast vs ネイティブ
読み取りループ
26,400
12,400
3,300
3.7倍
書き込みループ
26,500
5,500
2,300
2.4倍
pipeThrough
7,900
6,200
630
9.8倍
pipeTo
14,000
2,500
1,400
1.8倍
for-await-of
—
4,100
3,000
1.4倍
変換チェーン
チャンクごとのPromiseオーバーヘッドはチェーンの深さとともに複合します:
深さ
fast
ネイティブ
fast vs ネイティブ
3変換
2,900
300
9.7倍
8変換
1,000
115
8.7倍
バイトストリーム
パターン
fast
ネイティブ
fast vs ネイティブ
start + enqueue (React Flight)
1,600
110
14.6倍
バイト読み取りループ
1,400
1,400
1.0倍
バイトtee
1,200
750
1.6倍
レスポンスボディパターン
パターン
fast
ネイティブ
fast vs ネイティブ
Response.text()
900
910
1.0倍
レスポンス転送
850
430
2.0倍
フェッチ → 3変換
830
260
3.2倍
ストリーム構築
ストリームの作成も高速であり、これは短命ストリームにとって重要です:
タイプ
fast
ネイティブ
fast vs ネイティブ
ReadableStream
2,100
980
2.1倍
WritableStream
1,300
440
3.0倍
TransformStream
470
220
2.1倍
仕様準拠
fast-webstreamsは1,116のWeb Platform Testsのうち1,100を通過します。Node.jsのネイティブ実装は1,099を通過します。残りの16の失敗は、ネイティブと共有されているもの(未実装のtype: 'owning'転送モードなど)か、実際のアプリケーションに影響を与えないアーキテクチャの違いです。
私たちがこれをどのように展開しているか
ライブラリはグローバルなReadableStream、WritableStream、TransformStreamコンストラクターをパッチできます:
パッチはまたResponse.prototype.bodyをインターセプトして、ネイティブフェッチレスポンスボディを高速ストリームシェルでラップするので、fetch() → pipeThrough() → pipeTo()チェーンは自動的にパイプライン高速パスにヒットします。
Vercelでは、私たちはこれをフリート全体に展開することを検討しています。慎重かつ段階的に行います。ストリーミングプリミティブはリクエスト処理、レスポンスレンダリング、圧縮の基礎にあります。私たちは差が最大のパターンから始めます:React Server Componentストリーミング、レスポンスボディ転送、マルチ変換チェーンです。さらに拡大する前に本番環境で測定します。
正しい修正はアップストリームに
ユーザーランドライブラリはここでの長期的な答えであるべきではありません。正しい修正はNode.js自体にあります。
作業はすでに進行中です。Xでの会話の後、Matteo Collinaはnodejs/node#61807、「stream: add fast paths for webstreams read and pipeTo」を提出しました。このPRはこのプロジェクトからの2つのアイデアを直接Node.jsのネイティブWebStreamsに適用します:
read()高速パス:データがすでにバッファリングされているとき、ReadableStreamDefaultReadRequestオブジェクトを作成せずに直接解決済みPromiseを返します。これは仕様準拠です。なぜならread()はどちらにせよPromiseを返し、解決済みPromiseもマイクロタスクキューでコールバックを実行するからです。
pipeTo()バッチ読み取り:データがバッファリングされているとき、チャンクごとのリクエストオブジェクトを作成せずにコントローラーキューから複数の読み取りをバッチ処理します。背圧は各書き込み後にdesiredSizeをチェックすることで尊重されます。
PRはバッファリングされた読み取りで〜17-20%高速、pipeToで〜11%高速を示しています。これらの改善はすべてのNode.jsユーザーに無料で利益をもたらします。インストールするライブラリなし、パッチなし、リスクなしです。
James SnellのNode.jsパフォーマンス問題 #134は、さらにいくつかの改善の機会を概説しています:内部ソースストリームのためのC++レベルでのパイピング、遅延バッファリング、WritableStreamアダプターでの二重バッファリングの排除などです。これらのそれぞれが、さらにギャップを埋める可能性があります。
私たちはアイデアを上流に貢献し続けます。目標はfast-webstreamsが永遠に存在することではありません。目標は、WebStreamsがそれ自体で十分に高速であり、fast-webstreamsが必要なくなることです。
私たちが苦労して学んだこと
仕様は見た目よりも賢いです。私たちは多くの近道を試しました。ほとんどすべての試みがWeb Platform Testに失敗し、テストは通常正しかったのです。ReadableStreamDefaultReadRequestパターン、読み込みごとのPromise設計、注意深いエラー伝播:これらは、読み取り中のキャンセル、ロックされたストリームを介したエラー識別、thenableインターセプションが実際のコードが遭遇する現実のエッジケースであるために存在しています。
Promise.resolve(obj)は常にthenableをチェックします。これは避けられない言語レベルの動作です。解決するオブジェクトが.thenプロパティを持っている場合、Promise機構はそれを呼び出します。一部のWPTテストは意図的に読み取り結果に.thenを配置し、ストリームがそれを正しく処理することを検証します。私たちは、ホットパスで{value, done}オブジェクトが作成される場所に非常に注意する必要がありました。
Node.js pipeline()はWHATWG pipeToの代わりにはなりません。私たちはすべてのパイピングにpipeline()を使用することを望みました。それは72のWPT失敗を引き起こします。エラー伝播、ストリームロック、キャンセルセマンティクスは根本的に異なります。pipeline()は、私たちがチェーン全体を制御している場合にのみ安全です。それが私たちが上流リンクを収集し、完全なfast-streamチェーンに対してのみそれを使用する理由です。
Reflect.apply、.call()ではありません。WPTスイートはFunction.prototype.callをモンキーパッチし、実装がユーザー提供のコールバックを呼び出すためにそれを使用しないことを検証します。Reflect.applyが唯一安全な方法です。これは実際の仕様要件です。
私たちはfast-webstreamsの大部分をAIで構築しました
それを可能にした2つのことがあります:
驚くべきWeb Platform Testsは、1,116のテストを「何かを壊したか?」に対する即座の機械検証可能な答えとして提供しました。そして私たちは早期にベンチマークスイートを構築したので、各変更が実際にスループットを向上させたかどうかを測定できました。開発ループは次の通りでした:最適化を実装し、WPTスイートを実行し、ベンチマークを実行する。テストが失敗したとき、私たちはどの仕様不変条件に違反したかを知りました。ベンチマークが向上しなかったとき、私たちは元に戻しました。
WHATWG Streams仕様は長くて密度が高いです。興味深い最適化の機会は、仕様が要求することと現在の実装が行うことの間のギャップにあります。read()はPromiseを返さなければなりませんが、データがバッファリングされているときにそのPromiseがすでに解決済みであってはならないとは何も言っていません。その種の観察は、AIにアルゴリズムステップを分析させ、観測可能な動作をより少ない割り当てで維持できる場所を特定させることができるとき、直接的に行えます。
試してみてください
fast-webstreamsはexperimental-fast-webstreamsとしてnpmで利用可能です。「experimental」という接頭辞は意図的です。私たちは正確性に自信を持っていますが、これは活発に開発が行われている分野です。
もしあなたがサーバーサイドJavaScriptフレームワークやランタイムを構築していてWebStreamsのパフォーマンス限界に直面しているなら、私たちはあなたからの連絡を歓迎します。そしてもしNode.js自体でWebStreamsを改善することに興味があるなら、MatteoのPRは始めるのに最適な場所です。
続きを読む
原文を表示
When we started profiling Next.js server rendering earlier this year, one thing kept showing up in the flamegraphs: WebStreams. Not the application code running inside them, but the streams themselves. The Promise chains, the per-chunk object allocations, the microtask queue hops. After Theo Browne's server rendering benchmarks highlighted how much compute time goes into framework overhead, we started looking at where that time actually goes. A lot of it was in streams.
Turns out that WebStreams have an incredibly complete test suite, and that makes them a great candidate for doing an AI-based re-implementation in a purely test-driven and benchmark-driven fashion. This post is about the performance work we did, what we learned, and how this work is already making its way into Node.js itself through Matteo Collina's upstream PR.
The problem
Node.js has two streaming APIs. The older one (stream.Readable, stream.Writable, stream.Transform) has been around for over a decade and is heavily optimized. Data moves through C++ internals. Backpressure is a boolean. Piping is a single function call.
The newer one is the WHATWG Streams API: ReadableStream, WritableStream, TransformStream. This is the web standard. It powers fetch() response bodies, CompressionStream, TextDecoderStream, and increasingly, server-side rendering in frameworks like Next.js and React.
The web standard is the right API to converge on. But on the server, it is slower than it needs to be.
To understand why, consider what happens when you call reader.read() on a native WebStream in Node.js. Even if data is already sitting in the buffer:
A ReadableStreamDefaultReadRequest object is allocated with three callback slots
The request is enqueued into the stream's internal queue
A new Promise is allocated and returned
Resolution goes through the microtask queue
That is four allocations and a microtask hop to return data that was already there. Now multiply that by every chunk flowing through every transform in a rendering pipeline.
Or consider pipeTo(). Each chunk passes through a full Promise chain: read, write, check backpressure, repeat. An {value, done} result object is allocated per read. Error propagation creates additional Promise branches.
None of this is wrong. These guarantees matter in the browser where streams cross security boundaries, where cancellation semantics need to be airtight, where you do not control both ends of a pipe. But on the server, when you are piping React Server Components through three transforms at 1KB chunks, the cost adds up.
We benchmarked native WebStream pipeThrough at 630 MB/s for 1KB chunks. Node.js pipeline() with the same passthrough transform: ~7,900 MB/s. That is a 12x gap, and the difference is almost entirely Promise and object allocation overhead.
What we built
We have been working on a library called fast-webstreams that implements the WHATWG ReadableStream, WritableStream, and TransformStream APIs backed by Node.js streams internally. Same API, same error propagation, same spec compliance. The overhead is removed for the common cases.
The core idea is to route operations through different fast paths depending on what you are actually doing:
When you pipe between fast streams: zero Promises
This is the biggest win. When you chain pipeThrough and pipeTo between fast streams, the library does not start piping immediately. Instead, it records upstream links:
source → transform1 → transform2 → ...
When pipeTo() is called at the end of the chain, it walks upstream, collects the underlying Node.js stream objects, and issues a single pipeline() call. One function call. Zero Promises per chunk. Data flows through Node's optimized C++ path.
The result: ~6,200 MB/s. That is ~10x faster than native WebStreams and close to raw Node.js pipeline performance.
If any stream in the chain is not a fast stream (say, a native CompressionStream), the library falls back to either native pipeThrough or a spec-compliant pipeTo implementation.
When you read chunk by chunk: synchronous resolution
When you call reader.read(), the library tries nodeReadable.read() synchronously. If data is there, you get Promise.resolve({value, done}). No event loop round-trip. No request object allocation. Only when the buffer is empty does it register a listener and return a pending Promise.
The result: ~12,400 MB/s, or 3.7x faster than native.
The React Flight pattern: where the gap is largest
This is the one that matters most for Next.js. React Server Components use a specific byte stream pattern: create a ReadableStream with type: 'bytes', capture the controller in start(), enqueue chunks externally as the render produces them.
Native WebStreams: ~110 MB/s. fast-webstreams: ~1,600 MB/s. That is 14.6x faster for the exact pattern used in production server rendering.
The speed comes from LiteReadable, a minimal array-based buffer we wrote to replace Node.js's Readable for byte streams. It uses direct callback dispatch instead of EventEmitter, supports pull-based demand and BYOB readers, and costs about 5 microseconds less per construction. That matters when React Flight creates hundreds of byte streams per request.
Fetch response bodies: streams you don't construct yourself
The examples above all start with new ReadableStream(...). But on the server, most streams do not start that way. They start from fetch(). The response body is a native byte stream owned by Node.js's HTTP layer. You cannot swap it out.
This is a common pattern in server-side rendering: fetch data from an upstream service, pipe the response through one or more transforms, and forward the result to the client.
With native WebStreams, each hop in this chain pays the full Promise-per-chunk cost. Three transforms means roughly 6-9 Promises per chunk. At 1KB chunks, that gets you ~260 MB/s.
The library handles this through deferred resolution. When patchGlobalWebStreams() is active, Response.prototype.body returns a lightweight fast shell wrapping the native byte stream. Calling pipeThrough() does not start piping immediately. It just records the link. Only when pipeTo() or getReader() is called at the end does the library resolve the full chain: it creates a single bridge from the native reader into Node.js pipeline() for the transform hops, then serves reads from the buffered output synchronously.
The cost model: one Promise at the native boundary to pull data in. Zero Promises through the transform chain. Sync reads at the output.
The result: ~830 MB/s, or 3.2x faster than native for the three-transform fetch pattern. For simple response forwarding without transforms, it is 2.0x faster (850 vs 430 MB/s).
Benchmarks
All numbers are throughput in MB/s at 1KB chunks on Node.js v22. Higher is better.
Core operations
Operation
Node.js streams
fast
native
fast vs native
read loop
26,400
12,400
3,300
3.7x
write loop
26,500
5,500
2,300
2.4x
pipeThrough
7,900
6,200
630
9.8x
pipeTo
14,000
2,500
1,400
1.8x
for-await-of
—
4,100
3,000
1.4x
Transform chains
The Promise-per-chunk overhead compounds with chain depth:
Depth
fast
native
fast vs native
3 transforms
2,900
300
9.7x
8 transforms
1,000
115
8.7x
Byte streams
Pattern
fast
native
fast vs native
start + enqueue (React Flight)
1,600
110
14.6x
byte read loop
1,400
1,400
1.0x
byte tee
1,200
750
1.6x
Response body patterns
Pattern
fast
native
fast vs native
Response.text()
900
910
1.0x
Response forwarding
850
430
2.0x
fetch → 3 transforms
830
260
3.2x
Stream construction
Creating streams is also faster, which matters for short-lived streams:
Type
fast
native
fast vs native
ReadableStream
2,100
980
2.1x
WritableStream
1,300
440
3.0x
TransformStream
470
220
2.1x
Spec compliance
fast-webstreams passes 1,100 out of 1,116 Web Platform Tests. Node.js's native implementation passes 1,099. The 16 failures that remain are either shared with native (like the unimplemented type: 'owning' transfer mode) or are architectural differences that do not affect real applications.
How we are deploying this
The library can patch the global ReadableStream, WritableStream, and TransformStream constructors:
The patch also intercepts Response.prototype.body to wrap native fetch response bodies in fast stream shells, so fetch() → pipeThrough() → pipeTo() chains hit the pipeline fast path automatically.
At Vercel, we are looking at rolling this out across our fleet. We will do so carefully and incrementally. Streaming primitives sit at the foundation of request handling, response rendering, and compression. We are starting with the patterns where the gap is largest: React Server Component streaming, response body forwarding, and multi-transform chains. We will measure in production before expanding further.
The right fix is upstream
A userland library should not be the long-term answer here. The right fix is in Node.js itself.
Work is already happening. After a conversation on X, Matteo Collina submitted nodejs/node#61807, "stream: add fast paths for webstreams read and pipeTo." The PR applies two ideas from this project directly to Node.js's native WebStreams:
read() fast path: When data is already buffered, return a resolved Promise directly without creating a
ReadableStreamDefaultReadRequest object. This is spec-compliant because read() returns a Promise either way, and resolved promises still run callbacks in the microtask queue.
pipeTo() batch reads: When data is buffered, batch multiple reads from the controller queue without creating per-chunk request objects. Backpressure is respected by checking desiredSize after each write.
The PR shows ~17-20% faster buffered reads and ~11% faster pipeTo. These improvements benefit every Node.js user for free. No library to install, no patching, no risk.
James Snell's Node.js performance issue #134 outlines several additional opportunities: C++-level piping for internally-sourced streams, lazy buffering, eliminating double-buffering in WritableStream adapters. Each of these could close the gap further.
We will keep contributing ideas upstream. The goal is not for fast-webstreams to exist forever. The goal is for WebStreams to be fast enough that it does not need to.
What we learned the hard way
The spec is smarter than it looks. We tried many shortcuts. Almost every one of them broke a Web Platform Test, and the test was usually right. The ReadableStreamDefaultReadRequest pattern, the Promise-per-read design, the careful error propagation: they exist because cancellation during reads, error identity through locked streams, and thenable interception are real edge cases that real code hits.
Promise.resolve(obj) always checks for thenables. This is a language-level behavior you cannot avoid. If the object you resolve with has a .then property, the Promise machinery will call it. Some WPT tests deliberately put .then on read results and verify that the stream handles it correctly. We had to be very careful about where {value, done} objects get created in hot paths.
Node.js pipeline() cannot replace WHATWG pipeTo. We hoped to use pipeline() for all piping. It causes 72 WPT failures. The error propagation, stream locking, and cancellation semantics are fundamentally different. pipeline() is only safe when we control the entire chain, which is why we collect upstream links and only use it for full fast-stream chains.
Reflect.apply, not .call(). The WPT suite monkey-patches Function.prototype.call and verifies that implementations do not use it to invoke user-provided callbacks. Reflect.apply is the only safe way. This is a real spec requirement.
We built most of fast-webstreams with AI
Two things made that viable:
The amazing Web Platform Tests gave us 1,116 tests as an immediate, machine-checkable answer to "did we break anything?" And we built a benchmark suite early on so we could measure whether each change actually moved throughput. The development loop was: implement an optimization, run the WPT suite, run benchmarks. When tests broke, we knew which spec invariant we had violated. When benchmarks did not move, we reverted.
The WHATWG Streams spec is long and dense. The interesting optimization opportunities sit in the gap between what the spec requires and what current implementations do. read() must return a Promise, but nothing says that Promise cannot already be resolved when data is buffered. That kind of observation is straightforward when you can ask an AI to analyze algorithm steps for places where the observable behavior can be preserved with fewer allocations.
Try it
fast-webstreams is available on npm as experimental-fast-webstreams. The "experimental" prefix is intentional. We are confident in correctness, but this is an area of active development.
If you are building a server-side JavaScript framework or runtime and hitting WebStreams performance limits, we would love to hear from you. And if you are interested in improving WebStreams in Node.js itself, Matteo's PR is a great place to start.
Read more
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み