- Web Streams 標準は、ブラウザとサーバー間で一貫したデータストリーミングを実現するために設計されたが、現状では複雑さと性能上の限界により開発者体験が損なわれている
- 既存 API は、ロック(lock)管理、BYOB、バックプレッシャー(backpressure) などの設計上の制約により、使い勝手と実装の両面で不要な負担を生んでいる
- Cloudflare は、非同期反復(async iteration) ベースの新しいストリームモデルを提案しており、この方式は2倍から最大120倍高速な性能を示している
- 新 API は、シンプルな async iterable 構造、明示的なバックプレッシャーポリシー、同期/非同期の並行サポートにより、効率性と一貫性を高める
- このアプローチは、Node.js、Deno、Bun、ブラウザなどすべてのランタイムで統一的なストリーミングモデルを可能にし、今後の標準化議論の出発点となり得る
Web Streams の構造的限界
- WHATWG Streams 標準は 2014〜2016 年に開発され、ブラウザ中心に設計されたため、当時はasync iteration が存在せず、別個の reader/writer モデルが導入された
- その結果、ロック管理、複雑な読み取りループ、BYOB バッファ処理などの不要な手続きが生まれた
- ロック(locking)モデルはストリームを排他的に占有して並列消費を妨げ、
releaseLock() の呼び出し忘れでストリームが永続的にロックされる問題が起こる
- BYOB(Bring Your Own Buffer) 機能はメモリ再利用を目的としていたが、複雑なバッファ分離・転送モデルのため実際の活用度は低く、実装難易度も高い
- バックプレッシャー(backpressure) は理論上サポートされているが、
desiredSize の値が負でも enqueue() が成功するなど、実際には制御できない構造になっている
- 各
read() 呼び出しごとに Promise の生成が強制されるため、高頻度ストリーミングでは性能低下と GC 負荷を引き起こす
実務で明らかになった問題
fetch() のレスポンス本文を消費しないと コネクションプールの枯渇が発生し、tee() 使用時には 無制限のメモリバッファリングが起こる
TransformStream は 読み取り準備の有無に関係なく即時処理されるため、遅いコンシューマ環境ではバッファの急増を招く
- サーバーサイドレンダリング(SSR)では、数千個の小さなチャンク処理による GC スラッシングが発生し、性能が急激に低下する
- 各ランタイム(Node.js、Deno、Bun、Workers)はこれを緩和するために非標準の最適化パスを導入したが、その結果互換性と一貫性の低下が生じた
- Web Platform Tests は 70 個以上の複雑なテストファイルを要求しており、これは過剰な内部状態管理と直感的でない挙動の結果である
新しいストリーム API の設計原則
- ストリームはシンプルな async iterable として定義され、
for await...of で直接消費できる
- Pull-through 変換を採用し、コンシューマがデータを要求したときだけ処理を実行する
- 明示的なバックプレッシャーポリシー(strict, block, drop-oldest, drop-newest) を提供し、メモリ暴走を防ぐ
- バッチチャンク(Uint8Array[]) 単位でデータを渡し、Promise 生成コストを減らす
- バイト単位専用処理に簡素化し、BYOB や複雑なコントローラー概念を取り除く
- 同期(synchronous)パスのサポートにより、CPU 中心の処理で Promise オーバーヘッドを除去する
新 API の例と特徴
Stream.push() で簡単に writer/readable ペアを生成でき、Stream.text() で全文テキストを収集できる
Stream.pull() は 遅延(lazy)パイプラインを構成し、消費時点でのみ実行される
Stream.share() と Stream.broadcast() は 明示的なマルチコンシューマ管理をサポートする
- Sync/Async 並行 API(
Stream.pullSync(), Stream.textSync()) により、I/O のない演算で性能を最大化する
- Web Streams との相互運用のため、シンプルなアダプター関数で変換できる
性能比較と展望
- Node.js 基準のベンチマークでは最大 80〜90 倍、ブラウザでは最大 100 倍以上高速な処理速度が確認された
- 例: 3 段の変換チェーンで 275GB/s vs 3GB/s
- 性能向上は、非同期オーバーヘッドの除去、バッチ処理、pull ベース設計に起因する
- この実装は純粋な TypeScript/JavaScript で書かれており、ネイティブ実装ではさらなる向上の可能性がある
- Cloudflare はこのアプローチを標準化議論の出発点として提示し、開発者コミュニティからのフィードバックを求めている
結論
- Web Streams は当時の制約下では合理的だったが、現代の JavaScript の言語機能や開発パターンに合っていない
- 新しい async iterable ベースのモデルは、シンプルさ、性能、明示的制御をすべて満たし、ランタイム間で一貫したストリーミングエコシステムを構築できる可能性を示している
- Cloudflare は GitHub の jasnell/new-streams で参照実装、ドキュメント、サンプルコードを公開している
- 目標は新たな標準を策定することではなく、**「より良いストリーム API」**を議論するための実質的な出発点を用意することにある
1件のコメント
Hacker Newsのコメント
この記事で提案されているAPIよりも優れたStreamインターフェースを自分で設計した
既存の提案は
async iterator of UInt8Arrayという形だが、私はnext()が同期・非同期の結果をどちらも返せる構造を提案しているこうすることで
既存の構造より単一のイテレータでシンプルに反復できる
同期入力に同期変換を適用すれば、処理全体を同期で完結でき、コードの重複を減らせる
不要なPromise生成が減って性能向上が見込める
並行性制御が可能になり、async iteratorの限界を克服できる
あなたの方式から彼らの構造を簡単に作ることはできないが、逆は可能だ
I/O中心のイテレータは、バッファの無駄を防ぐためにT単位のチャンクを返す必要がある
Uint8Arrayを使う理由は、OSレベルのバイトストリームに合わせるためだ実際、Cベースのプロジェクトでもこうした構造が最も効率的なので、型情報を持つプロトコルはその上に積み上げる形が自然だ
以前のバージョンでは105倍まで差があった
async処理の最適化がNode 16で入って、そのとき一部のテストが壊れた記憶がある
Uint8Arrayという型は存在しないUint8Arrayは単にバイト配列を表すプリミティブ型であり、型情報はプロトコルではなくアプリケーションレベルで扱うべきだ参考: Clojure Transducersドキュメント
Async iterableも完璧な解決策ではない
Promiseとスタック切り替えのオーバーヘッドが大きく、小さな単位のデータを扱うときは性能が悪い
Lit-SSRではこれを解決するために、同期iterableの中にthunkを含める方式を使っていた
async処理が必要なときだけthunkを呼び出してawaitすることで、SSR性能を12〜18倍改善した
ただしStreams APIではこのような壊れやすい契約構造を採用しにくいため、
write()とwriteAsync()のように選択的な非同期処理が可能な構造が理想的だと思う同期generatorを活用した例をGitHubコードで共有する
核心は
step.value.then(value => this.next(value))の部分だnext(): {done, value: T} | Promise)が気に入った2013年の「Do not unleash Zalgo」論争以降、
MaybeAsync形式を避ける傾向があったがこの恐れはあまりに誇張されていて、高速で柔軟なAPI設計を妨げていると思う
複数の値を一度に取り込むユーティリティも作れるし、generatorの速度問題も実際にはそれほど大きくないと感じる
Node.jsでWeb Streamsを扱うのはつらい
ブラウザ中心に設計されているため、サーバー環境では使いづらい
単純な変換でもtransform streamで包む必要があり、
.pipe()のような直感的なチェイニングが難しいAsync iterableアプローチの方がはるかに自然で、
for-await-ofとも相性が良いWeb Streams仕様はあまりに抽象化偏重で、実用性に欠ける
私はそれが単なるクライアント–サーバー間互換性用だと思っていた
本当の利点は性能だけでなく、**環境間の一貫性(convergence)**にある
ReadableStreamがブラウザ、Worker、その他のランタイムで同じように動作すれば
コード移植性が高まり、backpressureバグの減少にもつながる
ストリーム層の標準化は、信頼できるストリーミングシステムを構築するうえでの核心だ
以前、Repeaterという抽象化を作ったことがある
Promiseコンストラクタをasync iterableに移したような概念で、イベントをpush/stopで制御する
Repeaterライブラリは週650万ダウンロードを記録するほど安定している
最近はstreamsの方を好むが、
tee()に関する批判は今でも有効だasync iterableを基本抽象化にする方向が正しいと思う
stopが関数でありPromiseでもあるのが興味深かったソースコードを見てから
伝統的なパターンとは違うが、人間工学的な設計のための意図的な選択かもしれないと思った
メール署名にも「Up, Up, Down, Down, Left, Right, Left, Right, B, A」と書くほど思い入れがある
私もAsyncIterableをより簡潔に使うためのラッパーを作ったことがある
fluent-async-iteratorで、
LambdaやCLIパイプラインでの小規模データストリーミングに役立った
今ごろはもっと良いAPIが出ていてほしかった
ReadableStream.tee()のbackpressureの挙動はNode.jsのpipe()と逆なので混乱する仕様書には「最も遅い出力が速度を決めるべき」とあるのに、実際の実装では速い側が消費されなくても詰まってしまう
新しいStream APIのようなpushベースの簡潔な構造の方が良いと思う
NodeとWeb Streamsは無限キューを置くことで、同期的に
res.write()を乱発できるようにしているが、このAPIはgeneratorベースのyieldフローを強制するので、より安全だ
Node.jsでundici(fetch) を使う際にコネクションプール枯渇の問題が起きるのは
ガベージコレクション言語の限界のためだ
明示的にリソースを閉じないと、GCのタイミング次第でリークが発生する
C++のRAII(reference counting) アプローチの方がむしろ安全だ
リソース解放に関しては、
using/await usingパターンがもっと広まってほしいC#の
usingのようにdispose/disposeAsyncをサポートする構造をDBドライバに適用しているところだベンチマークの数値(例: 530GB/s)はM1 Proのメモリ帯域幅(200GB/s)を超えているので、信頼しがたい
実装品質の管理が不十分なvibe-codedベンチマークである可能性が高い