Skip to content

WebSocketStream: receive backpressure is not implemented — a slow consumer buffers every received message in memory #5503

Description

@carlotestor

Bug Description

WebSocketStream's readable side never applies backpressure. Every parsed message is enqueued into the readable stream unconditionally, whether or not the consumer is reading:

  • The ReadableStream is set up without a pull algorithm (only start and cancel), so consumer demand is never observable:
    // 5. Let pullAlgorithm be an action that pulls bytes from stream .
    // 6. Let cancelAlgorithm be an action that cancels stream with reason , given reason .
    // 7. Let readable be a new ReadableStream .
    // 8. Set up readable with pullAlgorithm and cancelAlgorithm .
    const readable = new ReadableStream({
    start: (controller) => {
    this.#readableStreamController = controller
    },
    cancel: (reason) => this.#cancel(reason)
    })
  • #onMessage enqueues unconditionally, and the spec's step 4 ("Apply backpressure to the WebSocket") is a comment with no code:
    this.#readableStreamController.enqueue(chunk)
    // 4. Apply backpressure to the WebSocket.
  • Constructor step 7 ("Apply backpressure to the WebSocket") is a // TODO:
    // 7. Apply backpressure to the WebSocket.
    // TODO

controller.desiredSize is never consulted and the socket is never paused on behalf of the consumer. The existing onSocketDataparser.write() === falsesocket.pause() / onParserDrainsocket.resume() pair only reflects the parser's internal buffer, which drains as fast as frames can be parsed — it is unrelated to consumer demand.

Consequence: with a consumer that is slower than the sender (or momentarily stalled), every received message piles up in the readable queue — unbounded memory growth — and no TCP backpressure ever reaches the server. Since backpressure is the headline feature of the WebSocketStream API, this defeats its main use case. Note that webSocket.maxPayloadSize / maxFragments offer no protection here: each individual message is small and valid.

Reproducible By

npm i undici ws && node repro.js — the server (child process) pushes 200k × 1 KiB messages; the client reads one message per second.

'use strict'
// undici WebSocketStream: receive backpressure is not implemented.
// Server (child process) pushes 200k x 1 KiB messages; client reads ONE message
// per second. Expected: client memory stays bounded and the server's sends stall.
// Actual: every message is buffered in the client's ReadableStream queue.
//
// npm i undici ws && node repro.js
const { fork } = require('node:child_process')

const MESSAGES = 200_000
const SIZE = 1024 // 1 KiB per message ~= 200 MiB total

if (process.argv[2] === 'server') {
  const { WebSocketServer } = require('ws')
  const wss = new WebSocketServer({ port: 0, perMessageDeflate: false })
  wss.on('listening', () => process.send(wss.address().port))
  wss.on('connection', (ws) => {
    const payload = Buffer.alloc(SIZE, 0x61)
    let sent = 0
    const pump = () => {
      while (sent < MESSAGES) {
        ws.send(payload)
        if (++sent % 100_000 === 0) console.log(`[server] queued ${sent} messages`)
        if (ws.bufferedAmount > 8 * 1024 * 1024) return void setTimeout(pump, 10)
      }
      const check = setInterval(() => {
        if (ws.bufferedAmount === 0) {
          clearInterval(check)
          console.log(`[server] all ${MESSAGES} messages (${MESSAGES * SIZE / 1048576 | 0} MiB) fully flushed to the socket, even though the client read almost nothing`)
        }
      }, 100)
    }
    pump()
  })
} else {
  const server = fork(__filename, ['server'])
  server.once('message', async (port) => {
    const { WebSocketStream } = require('undici')
    const rss = () => `${(process.memoryUsage.rss() / 1048576).toFixed(0)} MiB`
    console.log(`[client] node ${process.version}, undici ${require('undici/package.json').version}, rss before connect: ${rss()}`)

    const stream = new WebSocketStream(`ws://127.0.0.1:${port}`)
    const { readable } = await stream.opened
    const reader = readable.getReader()

    // Slow consumer: one read per second. The readable has the default HWM (1),
    // so a backpressuring implementation would stop reading from the socket
    // after ~1 unread message until read() is called.
    for (let t = 1; t <= 10; t++) {
      await new Promise((resolve) => setTimeout(resolve, 1000))
      await reader.read()
      console.log(`[client] t=${t}s read=${t} of ${MESSAGES} rss=${rss()}`)
    }
    server.kill()
    process.exit(0)
  })
}

Expected Behavior

The readable is created with the default high-water mark of 1, so once ~1 unread chunk is queued (desiredSize <= 0) the implementation should stop reading from the socket until read() pulls, propagating TCP backpressure to the server (this is how Chromium's implementation of the same API behaves). For the repro: client RSS stays roughly flat, and the server's bufferedAmount never fully drains because its sends stall against a full TCP window.

Actual Behavior

The server flushed all ~195 MiB into the socket within the first second while the client had read a single message. Client RSS grew from 75 MiB to 337 MiB (+262 MiB ≈ payload plus per-chunk queue overhead) and all 199,990 unread messages stayed buffered in the readable queue:

[client] node v22.22.1, undici 8.7.0, rss before connect: 75 MiB
(node:2680200) [UNDICI-WSS] Warning: WebSocketStream is experimental! Expect it to change at any time.
[server] queued 100000 messages
[server] queued 200000 messages
[server] all 200000 messages (195 MiB) fully flushed to the socket, even though the client read almost nothing
[client] t=1s read=1 of 200000 rss=337 MiB
[client] t=2s read=2 of 200000 rss=337 MiB
[client] t=3s read=3 of 200000 rss=337 MiB
[client] t=4s read=4 of 200000 rss=337 MiB
[client] t=5s read=5 of 200000 rss=337 MiB
[client] t=6s read=6 of 200000 rss=337 MiB
[client] t=7s read=7 of 200000 rss=337 MiB
[client] t=8s read=8 of 200000 rss=337 MiB
[client] t=9s read=9 of 200000 rss=337 MiB
[client] t=10s read=10 of 200000 rss=337 MiB

Environment

  • Linux x64
  • Node.js v22.22.1
  • undici 8.7.0 (npm); code paths verified unchanged on main @ cb4c2f1
  • ws 8.21.0 (repro server only)

Additional context

  • The spec the implementation cites includes "Apply backpressure to the WebSocket" steps in both the constructor and the "receive a WebSocket message" algorithm, and the explainer names backpressure as the primary motivation for the API: https://github.com/ricea/websocketstream-explainer
  • I searched existing issues/PRs (WebSocketStream backpressure, desiredSize, flow control, memory/leak variants) and found no report covering this. Nearest neighbors are the WebSocket umbrella WebSockets #1811 and the fragment-count DoS fixed by GHSA-vxpw-j846-p89q, which is a different unbounded-growth vector (attacker-controlled fragments rather than a slow consumer).
  • A possible fix shape: resume the socket from a pull algorithm and, after the enqueue in #onMessage, socket.pause() when controller.desiredSize <= 0. Granularity is per socket chunk (one chunk can contain many frames), so the queue may overshoot the HWM by one chunk's worth of messages — consistent with other implementations.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions