⚒️

ReadableStreamが一度きりな理由をカーネルから辿る

に公開

はじめに — レスポンスが消えた日

POS APIの冪等性ミドルウェアを実装していたとき、奇妙なバグに遭遇しました。同じリクエストを2回送ると、2回目のレスポンスが {"note": "[missing response body]"} になります。原因を追うと、Response.body の ReadableStream が一度読まれた後に空になっていました。

const res = await fetch("https://api.example.com/data");
const first = await res.json();  // OK
const second = await res.json(); // TypeError: body used already

なぜ Response のボディは一度しか読めないのでしょうか?

この記事では、この問いに答えるためにカーネルのソケットバッファからWeb標準の設計思想まで掘り下げます。読み終わる頃には、「一度しか読めない」が合理的な設計であることが腑に落ちているはずです。

対象読者: fetchResponse を使ったことはあるが、なぜストリームが一度しか読めないのか考えたことがないWebエンジニアの方。JavaScript/TypeScriptの基本と、HTTPリクエスト/レスポンスの概念を前提とします。

ReadableStreamとは何か

結論から言うと、ReadableStreamはデータを小さなチャンク(chunk)に分けて順番に読み出すためのAPIです。ファイルダウンロード、動画ストリーミング、HTTPレスポンスなど、「データが少しずつ届く」場面で使われます。

ストリームの3つの状態

ReadableStreamには3つの主要な状態があります。

  • readable: データが読み出し可能な状態。ストリーム作成直後はこの状態です
  • closed: すべてのデータが読み出された後の状態。もうデータは残っていません
  • errored: エラーが発生した状態。以降の読み取りは不可能です

getReader() によるロック

ReadableStreamからデータを読み出すには、getReader() でリーダーを取得します。リーダーを取得すると、ストリームの locked プロパティが true になり、他の消費者(コンシューマー)からのアクセスがブロックされます。注意すべきは、「ロック」はストリームの内部状態(readable / closed / errored)とは別の概念であり、あくまで排他制御のためのプロパティです。

const stream = response.body;
const reader = stream.getReader(); // ストリームがロックされる

// 別のリーダーを取得しようとするとエラー
const reader2 = stream.getReader(); // TypeError: ReadableStream is locked

ロックが存在する理由は明確です。WHATWG Streams Standardの仕様策定者Domenic Denicola自身がGitHub Issue #241で説明しています。

"once a stream is being read by C++, JS cannot touch it, both for performance and implementation-complexity reasons."

C++ネイティブコードとJavaScriptの間の競合を防ぎ、パイプ中に予期しない .read() が呼ばれる「フットガン」を防止するための設計です。

response.json() の正体

普段使っている response.json()response.text() は、内部でストリームを最後まで読み切る便利メソッドです。裏では「リーダーを取得 → 全チャンクを読み出し → ストリームをclose」という処理が一括で実行されています。

つまり、response.json() を呼んだ時点で、ストリームは closed 状態になります。データは既にメモリ上のJavaScriptオブジェクトに変換済みで、ストリーム自体にはもう何も残っていません。

図: ReadableStreamの状態遷移。lockedは状態ではなくプロパティであり、ストリームの内部状態はreadable / closed / erroredの3つのみ

ここでアナロジーを使うと分かりやすいです。ストリームはペットボトルの水です。グラスに注いだら、ペットボトルには戻せません。 response.json() は「ペットボトルの水をすべてグラスに注ぐ」操作に相当します。

ここまでで「一度読んだら消える」という事実は分かりました。では、なぜそういう設計になっているのでしょうか? その答えはOSカーネルにあります。

カーネルのソケットバッファ — 一度しか読めない原点

ReadableStreamが一度しか読めない設計の原点は、OSカーネルのTCPソケットバッファにあります。Webブラウザやサーバーがネットワークからデータを受け取る仕組みを見てみると、「読んだら消える」が物理的な必然であることが分かります。

TCPソケットの受信バッファ

ネットワークからデータが届くと、カーネルはソケット受信バッファと呼ばれるメモリ領域にデータを格納します。このバッファはカーネル空間にあり、アプリケーションから直接アクセスすることはできません。

重要なのは、このバッファのサイズが有限であることです。Linuxの net.ipv4.tcp_rmem のデフォルト値を見てみます。

パラメータ デフォルト値 説明
min 4,096 bytes (4KB) メモリ圧迫時でも保証される最小値
default 87,380 bytes (~85KB) 新規ソケットの受信バッファ初期サイズ
max 6,291,456 bytes (~6MB) 自動チューニング時の最大値

数十KB〜数百KB。これが1つのTCPコネクションに割り当てられるバッファの現実的なサイズです。

read() システムコール — 「コピーして削除」

アプリケーションがデータを受け取るには、read() システムコールを使います。このとき何が起こるかを正確に理解することが、今回の問いの核心です。

  1. アプリケーションが read(fd, buf, size) を呼び出す
  2. カーネルが受信バッファ(sk_buff チェーン)からデータをユーザー空間にコピーする
  3. コピー完了後、カーネルバッファからデータを削除する
  4. 解放されたバッファ分だけ受信ウィンドウが拡大し、送信側にACKで通知される

Linux recv(2) マニュアルの MSG_PEEK フラグの説明が、この「削除」動作を間接的に裏付けています。

"This flag causes the receive operation to return data from the beginning of the receive queue without removing that data from the queue."

つまり、MSG_PEEK指定しない通常の read() / recv() は、データを受信キューから削除するのがデフォルト動作です。

カーネルソケットバッファのデータフロー。ネットワークからカーネルバッファに受信し、read()でユーザー空間にコピー後バッファから削除される流れ
図: カーネルソケットバッファのデータフロー。read()はコピーと同時にバッファから削除する

なぜ削除するのか?

答えはシンプルです。バッファが有限だからです。

85KBのバッファに新しいデータが次々と届く状況を想像してみてください。読んだデータをバッファに残し続けたら、新しいデータを格納する場所がなくなります。

バッファが満杯になると、TCPのフロー制御が発動します。受信側はTCPウィンドウサイズを0に設定して送信側に通知します。送信側はこのゼロウィンドウ通知を受け取ると、送信を完全に停止します。これがバックプレッシャーの仕組みです。

カーネルレベルでは、「読んだら削除する」は選択肢ではなく、有限リソースの中で通信を継続するための唯一の合理的な設計です。

なぜこの設計が必要なのか — メモリ効率とバックプレッシャー

「読んだら消える」設計がもたらすメリットは、主に3つあります。

メモリ効率 — 1GBを64KBで処理する

ストリーム処理の最大のメリットは、データ全体をメモリに載せる必要がないことです。

1GBのファイルをダウンロードする場合を考えます。全体をメモリに読み込むアプローチでは、1GBのメモリが必要です。一方、ストリーム処理ではチャンクごとに処理・解放するため、バッファサイズ(たとえば64KB)のメモリで済みます。

// 全読み込み — ファイル全体がメモリに載る
const response = await fetch("https://example.com/large-file.bin");
const allData = await response.arrayBuffer(); // 1GB がメモリに載る

// ストリーム処理 — チャンクごとに処理
const response = await fetch("https://example.com/large-file.bin");
const reader = response.body.getReader();
while (true) {
  const { done, value } = await reader.read(); // 数KB〜数十KBのチャンク
  if (done) break;
  await processChunk(value); // 処理後、チャンクは GC で解放される
}

ストリーム処理と全読み込みのメモリ使用量比較。1GBファイル処理時、全読み込みは約1GBのメモリを使用するがストリーム処理は約64KBで済む
図: ストリーム処理と全読み込みのメモリ使用量比較。同じ1GBファイルで約16,000倍の差が出る

バックプレッシャー — 消費者が生産者を制御する

ストリームのもう1つの重要な特性は、消費者の処理速度に合わせて生産者の送信速度を自動制御できることです。

WHATWG Streams Standardの要件文書にはこう書かれています。

"Without backpressure, slow writable streams in the chain will either cause memory usage to balloon as queued data grows without limit, or will cause data loss if the queue is capped."

もしバックプレッシャーがなかったら、送信側が消費側の処理速度を超えてデータを送り続け、メモリが際限なく膨張してOOM Killに至ります。TCPウィンドウサイズによるフロー制御は、これをカーネルレベルで自動的に防ぐ仕組みです。

TTFB(Time to First Byte)— 最初のチャンクで処理を開始できる

3つ目のメリットは、全データの到着を待たずに処理を開始できることです。

たとえば、サーバーサイドレンダリング(SSR)でHTMLをストリーミング配信するケースを考えます。

// ストリーミングSSR — ヘッダー部分が先にブラウザに届く
const encoder = new TextEncoder();
app.get("/page", (c) => {
  return c.body(
    new ReadableStream({
      async start(controller) {
        controller.enqueue(encoder.encode("<html><head>...</head><body>"));
        // ↑ ブラウザはここでCSSの読み込みを開始できる

        const data = await fetchSlowDatabase(); // 2秒かかるDB問い合わせ
        controller.enqueue(encoder.encode(`<main>${renderContent(data)}</main>`));
        controller.enqueue(encoder.encode("</body></html>"));
        controller.close();
      },
    })
  );
});

全体をバッファリングするアプローチでは、DB問い合わせの2秒間、ユーザーには何も表示されません。ストリーミングなら、<head> が届いた時点でブラウザはCSSやフォントの読み込みを開始できます。体感の待ち時間が大幅に短縮されるのは、最初のチャンクが届いた時点で処理を開始できるからです。これがTTFBの改善です。

Web標準はなぜストリームを採用したのか — Fetch APIの設計思想

カーネルレベルの「読んだら消える」設計がWeb標準にどう接続されたのか。ここではWHATWG Streams Standardの設計思想を見ていきます。

低レベルI/Oプリミティブへのマッピング

WHATWG Streams Standardの冒頭には、仕様の目的が明確に記されています。

"This specification provides APIs for creating, composing, and consuming streams of data that map efficiently to low-level I/O primitives."

つまり、ReadableStreamはカーネルの read() システムコールと同じ「読んだら消える」セマンティクスを、意図的にWeb APIに持ち込んだものです。

仕様書はさらにこう続けます。

"Large swathes of the web platform are built on streaming data: that is, data that is created, processed, and consumed in an incremental fashion, without ever reading all of it into memory."

汎用APIとしてのトレードオフ

Fetch APIは汎用的に設計されています。数KBのJSONレスポンスも、数GBのファイルダウンロードも、同じ Response オブジェクトで扱います。

ここで自然な疑問が湧きます。「普通のJSON APIにとっては過剰設計では?」

率直に言えば、そのとおりです。数KBのJSONレスポンスに対して、ストリームの一度しか読めない制約はオーバーキルに感じます。しかし、汎用APIとしては最も制約が厳しいケース(巨大ファイルのストリーミング)に合わせた設計が正しい選択です。

WHATWG Streams Standardの公式FAQも、このトレードオフを認めた上で設計判断を説明しています。

"Your usual use case is a single consumer, with allowances for multi-consumer via teeing."

消費者が1つ(single consumer)であることが通常のユースケースであり、複数回読みたい場合は tee() を使えばよい、という設計方針です。

Fetch APIの4層レイヤー構造。カーネルのソケットバッファからランタイム、Web API、アプリケーションまで「一度しか読めない」制約が全層を貫通する
図: Fetch APIの4層レイヤー構造。カーネルからアプリケーションまで「一度しか読めない」制約が全層を貫通する

response.clone() — 明示的にコストを払う

「一度しか読めない」制約に対する公式の回避策が response.clone() です。

const response = await fetch("https://api.example.com/data");
const clone = response.clone(); // ストリームを分岐(tee)

const json = await response.json(); // 元のレスポンスを消費
const text = await clone.text();    // クローンを消費

clone() は内部的に ReadableStream.tee() を使い、ストリームを2つのブランチに分岐させます。ただし、MDNが明示的に警告しているとおり、これにはメモリコストが伴います。

"If only one cloned branch is consumed, then the entire body will be buffered in memory."

片方のブランチだけを消費すると、もう片方にはボディ全体がバッファリングされます。さらに、tee() にはバックプレッシャーの問題が仕様レベルで存在しており(WHATWG Issue #1235)、遅い側のブランチにデータが無制限に蓄積される可能性があります。

つまり clone() は「一度しか読めない」制約を回避する手段ですが、メモリコストを明示的に支払う必要がある設計です。これはストリームの「読んだら消える」原則と整合しています。必要なら複製できる。ただし、タダではない。

Edge Runtimeでの重要性

この設計が特に重要になるのが、Cloudflare WorkersやVercel Edge FunctionsなどのEdge Runtime環境です。

ランタイム メモリ上限
Cloudflare Workers 128MB
Vercel Edge Functions 128MB
Deno Deploy 512MB

128MBのメモリ上限で100MBのレスポンスをバッファリングしたら、残り28MBでランタイム全体を動かすことになります。Cloudflareの公式ドキュメントが「TransformStreamnode:stream を使い、ペイロード全体をメモリにバッファリングするのではなくストリーミングすること」を明示的に推奨しているのは、この制約があるからです。

ミドルウェアとストリーム — 「触らないのが正義」

ストリームの特性を理解すると、Webフレームワークのミドルウェアがレスポンスボディに触らない設計になっている理由が見えてきます。

ほとんどのミドルウェアはボディを読まない

認証ヘッダーの検証、CORSヘッダーの付与、レートリミットのチェック。これらのミドルウェアがやるのはヘッダーやメタデータの処理であり、レスポンスボディには触れません

ストリームをそのままパススルーする = メモリコピーゼロ。ボディに触れなければコストはゼロです。これが「使った分だけ払う(pay-as-you-go)」の設計思想です。

Expressの body-parser との対比

Express時代のアプローチを振り返ると、設計思想の違いが鮮明になります。

// Express: リクエスト到着時に全体をメモリに読み込む
app.use(express.json()); // body-parser が全体をバッファリング
app.post("/api", (req, res) => {
  console.log(req.body); // パース済みオブジェクトにアクセス
});

// Hono: 明示的に読み取るまでボディは未消費
app.post("/api", async (c) => {
  const body = await c.req.json(); // この時点でストリームを消費
  return c.json({ received: body });
});

Express(2010年〜)の時代は、サーバーのメモリに余裕があり、JSONペイロードが主流でした。body-parserで事前にバッファリングする設計は合理的だったのです。

現代のEdge Runtime環境では、メモリ制約が厳しく、ストリーミングレスポンスや大きなペイロードへの対応が求められます。Hono(2022年〜)やNext.js App Router(2023年〜)がWeb標準のReadableStreamを採用したのは、この環境変化に対応するためです。

冒頭の問題を振り返る

冒頭のPOS APIの問題に戻ります。ミドルウェアでレスポンスのストリームを読んでしまうと、後続の処理でボディが空になります。これはバグではなく、ストリームの仕様どおりの動作です。

正しい対処法は2つあります。

方法1: clone() を使う

// Bad: ストリームを消費してしまう
const middleware = async (c, next) => {
  await next();
  const body = await c.res.json(); // ストリームを消費 — 後続でボディが消える
  cache.set(key, body);
};

// Good: clone() してから読む
const middleware = async (c, next) => {
  await next();
  const cloned = c.res.clone(); // ← ストリームを分岐
  const body = await cloned.json();
  cache.set(key, body);
};

方法2: データを別途保存する

// Good: ハンドラ内でデータを Context に保存
app.post("/api/order", async (c) => {
  const result = { orderId: "12345", status: "completed" };
  c.set("responseBody", result); // ← ストリームとは別にデータを保持
  return c.json(result);
});

方法2のほうがメモリ効率が良い場合が多いです。clone() はストリーム全体のバッファリングを伴いますが、Context変数への保存ならストリームの再読み取りが不要で、元のオブジェクトをそのまま利用できるためです。

ここまでHonoを中心に見てきましたが、他のフレームワークはボディをどう扱っているのでしょうか。

フレームワーク比較 — ボディの扱い方で見る設計思想

主要フレームワークがHTTPボディをどう扱っているかを比較すると、ストリーム設計の位置づけが俯瞰できます。

フレームワーク ボディの扱い 特徴
Hono Web標準 ReadableStream 明示的消費まで未読。Edge Runtime対応
Express body-parser で事前バッファリング req.body でオブジェクトとして即アクセス
NestJS Express基盤 + Pipes/Interceptors 型安全・構造化優先
FastAPI Pydantic で事前パース+バリデーション 型安全・バリデーション内蔵
Next.js App Router Web標準 Request/Response request.json() で明示的に消費

大きく分けると、**「便利さ優先(事前バッファリング)」「効率優先(ストリームパススルー)」**の2つの系譜があります。

[従来型: バッファリング優先]
  Express (2010~) → body-parser で全体読み込み
  NestJS (2017~)  → Express基盤 + 型安全レイヤー
  FastAPI (2018~) → 全体読み込み + Pydantic検証

[現代型: ストリーム優先]
  Hono (2022~)           → Web Standard ReadableStream
  Next.js App Router (2023~) → Web Standard Request/Response

HonoがWeb標準ストリームを採用した背景には、Cloudflare Workers / Deno Deploy等のEdge Runtimeをメインターゲットとしていることがあります。128MBのメモリ制限下では、ストリームのパススルー設計が単なる最適化ではなく必須要件になります。

どちらが「正しい」というわけではなく、動作環境とユースケースに応じたトレードオフです。メモリに余裕のあるNode.jsサーバーでシンプルなJSON APIを作るなら、Express/NestJSの事前バッファリングのほうが開発体験は良いでしょう。Edge Runtimeや大きなペイロードを扱う場合は、ストリーム前提の設計が適しています。

まとめ — 「一度しか読めない」は合理的な設計

ReadableStreamが一度しか読めないのは、カーネルレベルのI/Oプリミティブに忠実な設計だからです。各レイヤーの要点を振り返ります。

  • カーネル: ソケット受信バッファは有限(数十KB〜数百KB)。read() はデータをコピーすると同時にバッファから削除する。有限リソースの中で通信を継続するには、読んだデータを解放するしかない
  • ネットワーク: バッファが満杯になるとTCPウィンドウサイズを0にして送信を停止させる(バックプレッシャー)。これにより、消費者の処理速度に合わせたフロー制御が実現する
  • Web標準: WHATWG Streams Standardは「低レベルI/Oプリミティブへの効率的なマッピング」を設計原則としている。最も制約が厳しいケース(巨大ファイルのストリーミング)に合わせた汎用設計
  • フレームワーク: ストリームに触らなければメモリコピーゼロ。「使った分だけ払う」設計がEdge Runtime環境で特に重要になる

実務で意識すること

ストリームを扱うときは、「消費」を意識することが大切です。response.json()response.text() を呼んだ時点でストリームは消費されます。複数回読む必要があるなら、clone() で明示的にコストを払うか、パース済みのデータを変数に保存しておく。どちらのコストを選ぶかは、ユースケース次第です。

「一度しか読めない」は制約ではなく、カーネルからWeb標準まで一貫した設計上の意思決定です。

Discussion

ログインするとコメントできます