TL;DR

  • send() は、バイト列をキューに積んだ時点で戻ります。僕の環境では、まったく読まない受信側に対して、64MiBを約0.15〜0.25秒で渡し終えました。Node(ws)でもヘッドレスChromeでも同じでした。
  • bufferedAmount が数えるのは、まだOSに渡していない分だけです。受信側が止まっていても、Linuxのループバックでは最初の2.56MiB(Node)、2.75MiB(Chrome)の間は 0 のままでした。カーネルの送信・受信バッファが先に受け取ったからです。bufferedAmount === 0 は、相手に届いた印ではありません。
  • bufferedAmount が下がるのを待ってから send() する方法で守れるのは、送信側のメモリです。ソケットは速く読み、仕事を自分のメモリに積んで遅く処理する受信側は守れません。この実験では、5回とも400件中387〜393件(約24MiB)が受信側に溜まりました。8件のクレジット窓なら、溜まるのは8件でした。
  • 受信側の上限を超えるメッセージは、そのメッセージだけでは済みません。受信側は接続全体をコード1009で閉じ、直後に送った小さなメッセージも失われます。
  • 下の5つの短いスクリプトで、すべて再現できます。必要なのはNode 20、wsパッケージ、そして1つだけChromeです。

4つの思い込みを、順番に試す

WebSocketのコードは、だいたいこう書かれています。

socket.send(payload);

1行で、例外も出ません。相手が遅いときにバイト列がどこで待つのかは、この行からは分かりません。この行について僕を含め多くの人が置きがちな4つの前提を、小さな実験で確かめました。

bufferedAmount が見えるのは青い箱だけです。受信側カーネルのバッファが埋まるまでは、TCP自身のフロー制御がその手前を守ります。橙の箱は、何か作らない限り誰も守ってくれません。

思い込み1「send()が戻ったから、送れた」

実験の構成です。ハンドシェイクだけ済ませてあとはソケットを読まないサーバーと、64KiBのメッセージを1024個(合計64MiB)、同期ループで送るクライアントを用意します。

// A WebSocket server that accepts connections and then stops reading from the socket.
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8081 });
wss.on("connection", (ws) => {
  ws._socket.pause();          // stop reading: the kernel receive buffer fills, then the window closes
  console.log("client connected; not reading");
});
console.log("listening on ws://127.0.0.1:8081");
// Send 64 KiB messages as fast as possible to a server that is not reading.
import WebSocket from "ws";
const ws = new WebSocket("ws://127.0.0.1:8081");
const CHUNK = Buffer.alloc(64 * 1024, 1);
ws.on("open", () => {
  let sent = 0, firstBuffered = null;
  const t0 = process.hrtime.bigint();
  for (let i = 1; i <= 1024; i++) {            // 1024 x 64 KiB = 64 MiB in one synchronous loop
    ws.send(CHUNK);
    sent += CHUNK.length;
    if (firstBuffered === null && ws.bufferedAmount > 0) firstBuffered = sent;
  }
  const ms = Number(process.hrtime.bigint() - t0) / 1e6;
  console.log(`send() x1024 returned after ${ms.toFixed(1)} ms`);
  console.log(`bufferedAmount stayed 0 until ${(firstBuffered / 1048576).toFixed(2)} MiB had been "sent"`);
  console.log(`bufferedAmount now: ${(ws.bufferedAmount / 1048576).toFixed(2)} MiB of ${(sent / 1048576)} MiB`);
  console.log(`rss: ${(process.memoryUsage().rss / 1048576).toFixed(0)} MiB`);
  setTimeout(() => process.exit(0), 200);
});

片方の端末で node stall_server.mjs、もう片方で node probe_node.mjs を動かします。僕の環境での3回分です。

send() x1024 returned after 193.4 ms   (残り2回は152.3msと152.8ms)
bufferedAmount stayed 0 until 2.56 MiB had been "sent"
bufferedAmount now: 61.51 MiB of 64 MiB
rss: 129 MiB

ループは1秒もかからず終わり、例外も待ちもありませんでした。受信側は何も読んでいないのに、です。約61.5MiBが送信側プロセスの中に残っていました。同じ状況を、同じ止まったサーバーに対してヘッドレスChromeで試します。

// Same probe from a real browser WebSocket (headless Chrome). Needs: npm i puppeteer-core, google-chrome.
const puppeteer = require("puppeteer-core");
(async () => {
  const b = await puppeteer.launch({ executablePath: "/usr/bin/google-chrome", headless: "new", args: ["--no-sandbox"] });
  const p = await b.newPage();
  const http = require("http"); const srv = http.createServer((q,r)=>r.end("<!doctype html><title>x</title>")).listen(8082);
  await p.goto("http://127.0.0.1:8082/");
  const r = await p.evaluate(async () => {
    const ws = new WebSocket("ws://127.0.0.1:8081");
    await new Promise((res) => (ws.onopen = res));
    const chunk = new Uint8Array(64 * 1024);
    const t0 = performance.now();
    for (let i = 0; i < 1024; i++) ws.send(chunk);
    const sameTask = ws.bufferedAmount;
    const ms = performance.now() - t0;
    const samples = [];
    for (let k = 0; k < 5; k++) { await new Promise((r) => setTimeout(r, 400)); samples.push(ws.bufferedAmount); }
    return { ms, sameTask, samples, wss: typeof WebSocketStream, readyState: ws.readyState };
  });
  console.log(JSON.stringify(r));
  console.log("sameTask MiB", (r.sameTask / 1048576).toFixed(2), "later MiB", r.samples.map((x) => (x / 1048576).toFixed(2)).join(" "));
  await b.close(); srv.close();
})();
sameTask MiB 64.00 later MiB 61.25 61.25 61.25 61.25 61.25

ループの直後(同じタスクの中)では、Chromeは64MiB全部を報告します。これはWebSocketsの標準の記述と合っています。getterが返すのは、send() でキューに積まれ、「イベントループが最後にステップ1に到達した時点でまだネットワークに送られていない」バイト数で、現在のタスクの中で送った分も含まれる、とあります。その後は61.25MiBで安定しました。3回とも、バイト数は同じでした。タイミングを表示した回では、1024回の send() に約0.15〜0.25秒かかっています(別の日に再実行した結果は190〜240msでした)。バイト数はソケットバッファに、所要時間はマシンに左右されます。

標準は、上限に達したときのことも書いています。送るデータが「バッファに入れる必要があるが、バッファが一杯」で送れない場合、ブラウザはWebSocketに「full」の印を付けて接続を閉じます(WHATWG WebSockets、send())。そのバッファの大きさは書かれておらず、64MiBではそこに届きませんでした。

つまり send() は「キューに入れる」です。相手が受け取るかどうかは別の問題で、send() には答えられません。

思い込み2「bufferedAmountが0だから、相手に届いている」

同じ実行がそのまま答えです。bufferedAmount は、2.56MiB(Node、ws 8.22.0)を送るまで 0 のままで、Chromeでは同じ値が 64 − 61.25 = 2.75MiB でした。受信側は何も読んでいません。データはカーネルの中にありました。この環境の tcp_wmem は 4096 16384 4194304、tcp_rmem は 4096 131072 6291456(最小・既定・最大、単位はバイト、自動調整)で、ループバックなら2つのソケットバッファに数MiBは入ります。値は環境によって変わります。

これは仕様どおりです。標準には、bufferedAmount は「プロトコルのフレーミングのオーバーヘッドや、OSやネットワーク機器によるバッファリングを含まない」とあります(WHATWG WebSockets)。MDNには、接続が閉じても0に戻らず、send() を呼び続けると増え続ける、とあります。この点はブラウザでは試していません。

Nodeの ws ライブラリはドキュメントで2点だけ違います。すぐ送れたら 0 になること、そして標準と違ってフレーミングのバイトを含むことです。

bufferedAmount === 0 は「自分のキューが空」と読みます。「配達済み」とは読みません。配達を知りたいなら、相手アプリケーションからの確認応答が要ります。

思い込み3「bufferedAmountが減るのを待てば、遅い受信側に潰されない」

これは僕自身が意外でした。標準の例も bufferedAmount == 0 を待ってから次の更新を送っていますし、MDNの WebSocketStream のページは「ストリームのバックプレッシャーを自動的に活用できる」と説明しています。bufferedAmount で待てばバックプレッシャーになる、と考えるのは自然です。たしかにそれは、ネットワークと受信側カーネルからのバックプレッシャーです。では、受信側カーネルは問題なく、遅いのが受信側のアプリだったらどうでしょうか。

実験の構成です。受信側は、メッセージを届いた順に即座に読み、キューに積み、1件5msで処理します。送信側は64KiBのメッセージ400件を、3通りの方法で送ります。スクリプトは、受信側のキューが最も長くなった時点の件数を表示します。

// Does gating on bufferedAmount protect a slow consumer? Run: node slow_lab.mjs
import { WebSocketServer, WebSocket } from "ws";

const MSGS = 400, SIZE = 64 * 1024, WORK_MS = 5;           // the consumer needs 5 ms per message
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function run(mode) {
  const wss = new WebSocketServer({ port: 0 });
  await new Promise((r) => wss.on("listening", r));
  const port = wss.address().port;
  let peak = 0, done;                                         // peak = most messages ever waiting in the consumer's JS memory
  const finished = new Promise((r) => (done = r));
  wss.on("connection", (ws) => {
    const queue = []; let handled = 0, working = false;
    const pump = async () => {
      if (working) return; working = true;
      while (queue.length) {
        await sleep(WORK_MS); queue.shift(); handled++;
        if (mode === "credit") ws.send("ack");                // credit: one ack per message that is really finished
        if (handled === MSGS) done();
      }
      working = false;
    };
    ws.on("message", () => { queue.push(1); peak = Math.max(peak, queue.length); pump(); });
  });

  const c = new WebSocket(`ws://127.0.0.1:${port}`);
  await new Promise((r) => c.on("open", r));
  const payload = Buffer.alloc(SIZE, 7);
  let inFlight = 0, wake = null, peakBuffered = 0;
  c.on("message", () => { inFlight--; wake?.(); });
  const t0 = Date.now();
  for (let i = 0; i < MSGS; i++) {
    if (mode === "bufferedAmount") while (c.bufferedAmount > 256 * 1024) await sleep(5);
    if (mode === "credit") while (inFlight >= 8) await new Promise((r) => (wake = r));   // window of 8 messages
    c.send(payload); inFlight++;
    peakBuffered = Math.max(peakBuffered, c.bufferedAmount);
  }
  const sendMs = Date.now() - t0;
  await finished;
  console.log(`${mode.padEnd(15)} sender done sending after ${String(sendMs).padStart(5)} ms | sender peak bufferedAmount ${(peakBuffered/1048576).toFixed(2)} MiB | consumer peak backlog ${String(peak).padStart(3)} messages (${(peak*SIZE/1048576).toFixed(1)} MiB)`);
  c.close(); wss.close();
}
for (const m of ["naive", "bufferedAmount", "credit"]) await run(m);

node slow_lab.mjs を5回実行した結果です(Node 20.19.2、ws 8.22.0、ループバック)。

送信側400件を渡し終えるまで送信側の bufferedAmount 最大受信側のバックログ最大
naive: ループで送るだけ62〜78ms22.57MiB388〜390件(24.3〜24.4MiB)
bufferedAmount: 256KiB以下になるまで待つ108〜216ms0.25MiB387〜393件(24.2〜24.6MiB)
credit: 未応答は最大8件約2050ms0MiB8件(0.5MiB)

(各セルは5回の範囲です。naive の送信側の最大 bufferedAmount だけは、毎回22.57MiBでした。)

注目すべきは真ん中の行です。送信側の自分のキューは0.25MiBにきちんと収まっています。ところがデータは消えていません。受信側のコードは届いたそばから読むので、TCPのウィンドウは閉じず、bufferedAmount は増えず、送信側は待たずに、24MiBがそのまま相手のJavaScript配列に溜まりました。問題が移動しただけです。

3行目はクレジット窓です。受信側が1件処理し終えるたびに小さな "ack" を返し、送信側は未応答を最大8件に抑えます。バックログは構造上8件で止まります。かかった時間は約2秒ですが、これは受信側が実際に必要とする時間(400 × 5ms)です。送信側が、消費する側のペースに合わされた、ということです。

bufferedAmount が不要になったわけではありません。ネットワークが遅いときに、送信側自身のメモリを抑える道具としては正しい選択です。2つは組み合わせられます。

  1. 受信側アプリに対して: クレジット窓
  2. ネットワークと受信側カーネルに対して: bufferedAmount の上限

思い込み4「メッセージサイズは性能のつまみにすぎない」

RFCではメッセージを複数フレームに分割できるので、サイズは何でもよさそうに見えます(RFC 6455 §5.4)。それでも受信側は最大サイズを決めます。同じRFCにはクローズコード1009があり、「処理するには大きすぎるメッセージを受け取ったため、エンドポイントが接続を終了する」と定義されています(§7.4.1)。プラットフォームもこうした上限を公開しています。たとえばCloudflare Durable Objectsのドキュメントには、受信するWebSocketメッセージは32MiBとあります(limits)。実験では ws の maxPayload を1MiBにしました。

// What happens to a message that is larger than the receiver allows? Run: node limit_lab.mjs
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 0, maxPayload: 1024 * 1024 });   // receiver accepts at most 1 MiB per message
await new Promise((r) => wss.on("listening", r));
wss.on("connection", (ws) => {
  ws.on("message", (m) => console.log(`server got a message of ${m.length} bytes`));
  ws.on("error", (e) => console.log(`server error: ${e.message}`));
});
const c = new WebSocket(`ws://127.0.0.1:${wss.address().port}`);
await new Promise((r) => c.on("open", r));
c.send(Buffer.alloc(512 * 1024));                       // fits
c.send(Buffer.alloc(2 * 1024 * 1024));                  // does not fit
c.send(Buffer.alloc(16));                               // a perfectly small message, sent afterwards
await new Promise((r) => c.on("close", (code, reason) => { console.log(`client: closed with code ${code} ${reason}`); r(); }));
wss.close();
server got a message of 524288 bytes
server error: Max payload size exceeded
client: closed with code 1009 

512KiBは届きました。2MiBは接続ごと閉じられ、その直後に送った16バイトのメッセージは届きませんでした。サーバーは2回目の「got a message」を表示していません。大きすぎるメッセージ1件の影響範囲は接続全体で、後ろにいた無関係のメッセージも巻き込まれます。

実務上の決め方はこうです。やり取りする相手すべての上限のうち最小のものより小さい最大メッセージサイズを決め、それを超えるものは自分で分割し、分割した各チャンクも他のメッセージと同じゲート(まずクレジット、次に bufferedAmount)を通して送ります。分割すれば、メモリが有界になり、待つ場所もはっきりします。再送はTCPがすでにやっているので、チャンクが失われるのは接続が失われるときです。

WebSocketStreamなら解決するのか

MDNは WebSocketStream を、ストリームの上に作られたPromiseベースのAPIで、「ストリームのバックプレッシャーを自動的に活用できる」と説明しています。実験的で非標準であり、「現時点ではどの仕様にも含まれていない」とも書かれています(MDN)。使ったヘッドレスChrome 154では、typeof WebSocketStream は "function" でした。動作は試していませんし、他のブラウザやReact Nativeでの有無も確認していません。なお、書き込み側のストリームのバックプレッシャーが反映するのも、やはりトランスポートの状態です。相手アプリの遅さに対するクレジット窓は、どのストリームAPIも無償では提供しません。

どれを使えばよいか

状況使うもの
小さなメッセージ、低頻度、受信側の処理が軽い何も要りません。素の send() で十分です。
遅いネットワークで、自分のメモリを超えうる突発的な送信(大きなペイロード、速い生産者)bufferedAmount でゲートします(ポーリング、または ws の send(data, cb) のようなコールバック)。readyState が OPEN でなくなったら止めます。
受信側の処理が送信側の生産より遅くなりうるアプリの確認応答によるクレジット窓。メッセージサイズがばらつくならバイト数で数えます。
どの受信側の上限も超えうるペイロード最小の上限より小さいサイズに決め、分割し、上のゲートを通して送ります。
「相手が処理した」ことを確実に知りたいそのメッセージのアプリ層の確認応答。アプリより下のどの層も、それは教えてくれません。

最初に疑う失敗

  • 終了条件のない while (bufferedAmount > high) ループ。 症状: 接続が切れたあとも、タブやプロセスが回り続けます。直し方: readyState も確認し、OPEN でなければ失敗として返します(MDNによれば、閉じても値は0に戻りません)。
  • エラー経路でクレジットを返さない。 症状: 1件失敗しただけで、送信側が永遠に止まります。直し方: 確認応答を finally で返すか、否定応答を返します。
  • サイズがばらつくのにクレジットを件数で数える。 症状: 窓8件で問題なかったのに、1件が30MiBのとき破綻します。直し方: バイト数で数えます。
  • 再接続時にクレジットのカウンタを戻さない。 症状: 再接続後、送信側は永遠に返らない8件が飛行中だと思い込み、何も送りません。直し方: 新しい接続が開いたら窓をリセットします。
  • 固定間隔のポーリング。 症状: スループットがスリープの長さで頭打ちになるか、無駄な起床が増えます。ここでの5msは問題ありませんでしたが、自分の環境で測ってください。
  • サイズ超過の切断を一時的なエラーとして扱う。 症状: 再接続し、同じ大きすぎるメッセージを再送し、また切られる、を永遠に繰り返します。直し方: そのペイロードにとって1009は恒久的な失敗として扱います。

試してみる

  1. mkdir lab && cd lab && npm init -y && npm i ws@8 を実行し、上のスクリプトを package.json の隣に保存します。
  2. 端末1で node stall_server.mjs、端末2で node probe_node.mjs を動かします。bufferedAmount が数MiBの間 0 のままで、その後、送った総量近くまで増えれば想定どおりです。
  3. node slow_lab.mjs を動かします。成功した場合は上の表と同じ形になります。最初の2つの送信側は受信側のバックログが400に近く、credit は8件ちょうどです。
  4. node limit_lab.mjs を動かします。1009で閉じられ、「got a message of 16 bytes」の行が出なければ成功です。
  5. ブラウザ版は、puppeteer-core を入れ、executablePath を自分のChromeに向け、node stall_server.mjs と node probe_chrome.cjs を動かします。
  6. slow_lab.mjs の WORK_MS や窓の大きさ(8)を変え、実行する前にバックログと所要時間を予想してみてください。

この計測が届く範囲

確認できたこと: 1台のマシン(Linux 6.12、ループバック、Node 20.19.2、ws 8.22.0、ヘッドレスChrome 154)で、上の表の数字を確認しました。数字は、印字されたスクリプトの出力そのままです(slow_lab.mjs 5回、各プローブ3回、limit_lab.mjs 1回)。WebSocketsの標準、RFC 6455、MDN、ws のドキュメント、Cloudflareの上限ページからの引用は、書く際に元のページで確認しました。

確認できていないこと: 実際のネットワーク(カーネルが吸収した2.56MiBや2.75MiBはソケットバッファの大きさに依存し、環境で変わります)、他のブラウザ、React Native、Safari、ブラウザの送信バッファが実際に一杯になったときの挙動、接続が閉じたあとの bufferedAmount、WebSocketStream の動作、permessage-deflate(RFC 7692)、Cloudflareのランタイム自体(ドキュメントの上限を引用しただけです)。1件5msや窓8件は実験用の値で、推奨値ではありません。

受け取れる量は、相手に言わせる

send() は「キューに入れた」。bufferedAmount は「まだカーネルに渡していない」。どちらも「相手が追いついている」とは言っていません。相手が自分より遅くなりうるなら、相手が自分で制御できる数字、つまりクレジットで、遅さを申告させます。