
2026/02/27 23:02
JavaScriptでより優れたストリームAPIを実装できる可能性があります。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Web Streams は、ブラウザの
と Node.js、Deno、Bun、および Cloudflare Workers におけるサーバー側ストリーミングを可能にする標準ですが、その設計上の選択がパフォーマンスボトルネックと複雑さを生み出しています。fetch()
スペックの「リーダー取得/ロッキングモデル」、アドバイザリ・バックプレッシャー、BYOB API、および大量のプロミス生成は実際には次のような失敗につながります:レスポンスが完全に消費されないと接続プールが枯渇する、によってメモリ崩壊(1 つの遅いブランチが無限にバッファリングできる)、tee()の書き込みで下流へ波及するイーガーバッファリング、およびサーバー側レンダリングパイプラインでリクエストあたり CPU 時間の半分以上を消費する GC プレッシャー。TransformStream
実行時チームは、これらの問題を緩和するために非標準の「direct streams」または「identity transform」最適化を導入していますが、この断片化は移植性を損ないます。
ベンチマークでは、単純な async‑iterator に基づく代替案が 80〜90 倍速いことが示されています。提案された API は明示的なリーダー/ライター/ロックを排除し、厳格なバックプレッシャーを強制し、のチャンクをバッチ処理し、ゼロ割り当てケースのために同期高速経路を提供し、明示的なマルチコンシューマプリミティブを備えています。Uint8Array[]
このモデルを採用すると、サーバー側レンダリングとストリーミングワークロードがより速くなり、メモリ消費が減少し、Web Streams と共存または将来のランタイムで置き換えることのできる統一された高性能ストリーミングインターフェースが実現でき、JavaScript エコシステム全体の断片化を低減できます。
本文
2026‑02‑27 – 24 分読了
ストリームでデータを扱うことは、アプリケーション構築の根幹です。
ストリーミングをあらゆる場所で機能させるために、WHATWG Streams Standard(俗称 Web streams)が設計されました。この標準はブラウザとサーバー間で共通の API を確立しようというものです。ブラウザに実装された後、Cloudflare Workers、Node.js、Deno、Bun に採用され、
fetch() などの API の基盤となりました。これは大規模な取り組みであり、その設計者たちは当時持っていた制約とツールを駆使して難しい問題に対処していました。
Web streams を数年にわたり実装・改良し、Node.js と Cloudflare Workers で導入し、顧客やランタイムの本番障害をデバッグし、開発者が直面する多くの典型的な落とし穴を解消してきた結果、私は標準 API に根本的な使い勝手とパフォーマンス上の問題があると確信しています。これらは単なるバグではなく、10 年前には理にかなっていた設計決定が現在の JavaScript 開発者のコード記述スタイルと合致しない結果です。
本稿では Web streams に対する私が見ている根本的な問題を掘り下げ、JavaScript 言語プリミティブを中心に据えた代替アプローチを提示します。このアプローチはベンチマーク上で Cloudflare Workers, Node.js, Deno, Bun そして主要ブラウザの全てで 2 倍から 120 倍まで高速化できることが示されています。改善点は巧妙な最適化ではなく、モダン JavaScript 言語機能をより効果的に活用した本質的に異なる設計選択によるものです。
背景
Streams Standard は 2014 年から 2016 年の間に開発され、「低レベル I/O プリミティブへ効率的にマッピングできるデータストリームを作成、合成、および消費する API を提供する」という野心的な目標を掲げました。
Web streams が登場する以前、ウェブプラットフォームにはストリーミングデータを扱う標準手段はありませんでした。Node.js には当時から独自のストリーム API が存在していましたが、WHATWG は Web ブラウザだけを対象にした仕様であるため、それを出発点とはしませんでした。
Web streams の設計は JavaScript における非同期イテレーション(
for await…of)より前のものです。for await…of 構文は ES2018 で登場し、Streams Standard が最初に確定された 2 年後でした。このタイミング上、API は当初「非同期シーケンスを消費するための慣用的手段」を活かせませんでした。代わりに仕様は独自のリーダ/ライタ取得モデルを導入し、その決定が API のあらゆる側面へ波及しました。
共通操作への過剰な儀式
ストリームで最も頻繁に行う作業は「完了まで読み込む」ことです。Web streams では次のようになります:
// まず、ストリームを独占ロックするリーダーを取得… const reader = stream.getReader(); const chunks = []; try { // 次に read を繰り返し呼び出し、戻ってくる Promise を待つ while (true) { const { value, done } = await reader.read(); if (done) break; chunks.push(value); } } finally { // 最後にストリームのロックを解放 reader.releaseLock(); }
このパターンは「ストリーミング固有」と誤解されがちですが、実際にはリーダー取得・ロック管理・
{ value, done } プロトコルといった設計選択に過ぎません。これらは Web streams 仕様が書かれた当時の事情によるものです。
非同期イテレーションは「時間を経て到着するシーケンス」を扱うために存在しますが、ストリーム仕様が書かれたときにはまだ存在しませんでした。ここで生じる複雑さは純粋な API オーバーヘッドであり、根本的な必要性ではありません。
Web streams が
for await…of をサポートするようになった今の代替アプローチ:
const chunks = []; for await (const chunk of stream) { chunks.push(chunk); }
これはボイラープレートが格段に少なくなる点で優れていますが、すべてを解決しているわけではありません。非同期イテレーションは設計されていない API に後付けされたため、BYOB(自前バッファ)読み取りなどの機能はイテレーション経由では利用できません。
ロック問題
Web streams は「複数消費者が読み取りをインターリーブしない」ようにロックモデルを採用しています。
getReader() を呼ぶとストリームはロックされ、ロック中は他のコードが直接ストリームから読み取ったりパイプしたりキャンセルしたりできません。ロックを保持しているコードだけが操作できます。
async function peekFirstChunk(stream) { const reader = stream.getReader(); const { value } = await reader.read(); // ← releaseLock() を呼ばずに戻ると return value; } const first = await peekFirstChunk(stream); // TypeError: Cannot obtain lock — stream is permanently locked for await (const chunk of stream) { /* 実行されない */ }
実装者側ではロックモデルがかなりの内部 bookkeeping を必要とします。各操作でロック状態を確認し、リーダーを追跡し、ロック・キャンセル・エラー状態との相互作用により多くの境界ケースを正しく処理する必要があります。
BYOB:報酬のない複雑さ
BYOB(Bring Your Own Buffer)読み取りは、ストリームからデータを読む際にメモリバッファを再利用できるよう設計されました。高スループット環境で重要な最適化ですが、実務では測定可能な恩恵がほとんどありません。API はデフォルト読み取りよりも大幅に複雑で、専用リーダー型(
ReadableStreamBYOBReader)や ReadableStreamBYOBRequest などの特殊クラスを必要とし、ArrayBuffer の分離(detachment)意味論を理解する必要があります。
バッファを BYOB 読み取りに渡すと、そのバッファはストリームへ転送されて「デタッチ」されます。結果として別のメモリ領域を指す新しいビューが返ってきます。この転送ベースモデルはエラーが起こりやすく、混乱しやすいです:
const reader = stream.getReader({ mode: 'byob' }); const buffer = new ArrayBuffer(1024); let view = new Uint8Array(buffer); const result = await reader.read(view); // 'view' はデタッチされて使用不能になるはず // (実装によっては必ずしもそうならない) result.value is a NEW view, possibly over different memory view = result.value; // 代入が必要
さらに BYOB は非同期イテレーションや
TransformStream と併用できません。ゼロコピー読み取りを望む開発者は、手動リーダー・ループに戻ることを強いられます。
実装者側では BYOB が大きな複雑さをもたらします。ストリームは保留中の BYOB 要求を追跡し、部分的な埋め込みを処理し、バッファ分離を正しく管理し、BYOB リーダーと基盤ソース間で調整する必要があります。Readable byte streams の Web Platform Tests には、デタッチされたバッファ、悪いビュー、レスポンス後の enqueue 順序など、BYOB エッジケース専用のテストが多数含まれています。
バックプレッシャー:理論上は良いが実際には壊れている
バックプレッシャー(遅い消費者が高速プロデューサーに減速を要求できる機能)は Web streams の主要概念です。理論的には妥当ですが、実装上は深刻な欠陥があります。
主なシグナルは
desiredSize で、プラス(データ欲しい)、ゼロ(容量満タン)、マイナス(オーバーフロー)または null(閉じた状態)が取れます。プロデューサーはこの値をチェックし、正の値でないときは enqueue を停止すべきです。しかし何も強制するものがなく、controller.enqueue() は常に成功します。
new ReadableStream({ start(controller) { // これを止めることはできません while (true) { controller.enqueue(generateData()); // desiredSize: -999999 } } });
ストリーム実装はバックプレッシャーを無視することができますし、いくつかの仕様上定義された機能(例:
tee())はバックプレッシャーを破棄します。tee() は単一ストリームから二つの分岐を作ります。片方が他方より速く読み取ると、内部バッファにデータが溜まり、メモリ消費が無制限に増加します。高速消費者は未満速度の消費者が追いつくまで膨大なメモリを占有し続けます。
Web streams は
highWaterMark オプションとカスタマイズ可能なサイズ計算でバックプレッシャー挙動を微調整する手段を提供しますが、これらも desiredSize と同様に無視されやすく、多くのアプリケーションは単にそれらを見逃しています。WritableStream 側でも同様です。WritableStream は highWaterMark と desiredSize を持ち、データプロデューサーが注意すべき writer.ready プロミスがありますが、実際には多くの場合無視されています。
const writable = getWritableStreamSomehow(); const writer = writable.getWriter(); // プロデューサーは writer.ready を待つべきです await writer.ready; await writer.write(...);
実装者側ではバックプレッシャーが追跡キューサイズ、desiredSize 計算、適切なタイミングでの pull() 呼び出しを正しく実装する必要があります。これにより複雑さが増す一方で保証は得られません。
プロミスの隠れたコスト
Web streams 仕様は多くの箇所でプロミス生成を要求しており、ホットパスで頻繁に発生し、ユーザーには見えません。
read() 呼び出しごとに単なる Promise を返すだけではなく、内部的にキュー管理や pull() コーディネーション、バックプレッシャー信号のための追加プロミスが生成されます。
このオーバーヘッドは、バッファ管理・完了通知・バックプレッシャーシグナルを Promise に依存する仕様設計に起因します。実装固有の部分もありますが、多くは仕様通りに従う限り不可避です。高頻度ストリーミング(ビデオフレーム、ネットワークパケット、リアルタイムデータ)ではこのオーバーヘッドが顕著になります。
パイプライン内での問題はさらに悪化します。各
TransformStream はソースとシンク間に追加の Promise 機構を挿入し、データが即座に利用可能でもプロミス処理が走ります。仕様には同期高速経路が定義されていないためです。
実際の失敗例
未消費ボディでリソース枯渇
fetch() が返すレスポンスは ReadableStream です。ステータスのみ確認し、ボディを消費もキャンセルもしなかった場合、何が起こるでしょうか?実装により結果は異なりますが、共通する問題はリソースリークです。
async function checkEndpoint(url) { const response = await fetch(url); return response.ok; // ボディは消費もキャンセルもしない } for (const url of urls) { await checkEndpoint(url); }
ループ内でこれを繰り返すと、接続プールが枯渇します。ストリームは基盤接続への参照を保持し、明示的に消費またはキャンセルされない限り、ガベージコレクションまで接続が残ります。負荷下では十分に早く GC が起きない可能性があります。
これらの問題に対する解決策
| 問題 | 対応 |
|---|---|
| 未消費ボディ | Pull シグナリングで何も発生しないため、隠れたリソース保持が無い |
| Tee メモリ崩壊 | は明示的にバッファ構成を要求。高速/低速消費者の差異による無制限増加は防止 |
| Transform バックプレッシャーギャップ | Pull‑through 変換はオンデマンドで実行され、データは必要時のみフローし、中間バッファに流れない |
| SSR の GC スラッシュ | バッチ化されたチャンク()で非同期オーバーヘッドを平滑化。CPU 集中型ワークロードでは により Promise 割当を完全に排除 |
パフォーマンス
以下は Node.js v24.x、Apple M1 Pro で行ったベンチマーク(10 回平均)です。
| シナリオ | 代替手法 | Web streams | 差 |
|---|---|---|---|
| 小チャンク (1 KB × 5000) | ~13 GB/s | ~4 GB/s | 約3×高速 |
| 微小チャンク (100 B × 10000) | ~4 GB/s | ~450 MB/s | 約8×高速 |
| 非同期イテレーション (8 KB × 1000) | ~530 GB/s | ~35 GB/s | 約15×高速 |
| 3つの変換を連鎖 (8 KB × 500) | ~275 GB/s | ~3 GB/s | 約80–90×高速 |
| 高頻度 (64 B × 20000) | ~7.5 GB/s | ~280 MB/s | 約25×高速 |
特に 3 つの連鎖変換結果は顕著です。Pull‑through 植物が Web streams のパイプラインを悩ます中間バッファリングを排除しています。
今後
この投稿は議論を始めるために公開しています。
何が正しく、何が抜けているのか? このモデルに合わないユースケースはあるのか? 移行パスはどのようになるべきか?
参照実装は https://github.com/jasnell/new-streams にあります。
API.md をご覧ください。サンプルディレクトリには動作コードが揃っています。
問題点や議論、プルリクエストを歓迎します。Web streams の課題に関する経験がある方、またはこのアプローチのギャップを見つけた方はぜひお知らせください。ただし、「新しいオブジェクトを使うべきだ」と主張するわけではありません。現在の Web Streams 状況を超えて第一原理に戻る議論を起こすことが目的です。