Skip to content

Backpressure

Broadcasting feels free until one client cannot drink as fast as you pour. A phone on a weak network, a tab the OS throttled, a laptop that went to sleep — these clients read slowly, and your fast server keeps calling send. The unsent data has to go somewhere, and that somewhere is the server’s memory. This is backpressure, and ignoring it is how a single slow client takes down a server.

ws.send(data) does not block until the client receives the data — it hands the bytes to the OS socket buffer and returns immediately. If the client reads slowly, that buffer fills, and further unsent data queues inside your Node process. Send to a stuck client in a tight loop and you are effectively building an unbounded in-memory queue:

flowchart LR
  PROD["server<br/>produces fast"] --> SEND["ws.send()"]
  SEND --> BUF["per-socket send buffer<br/>(ws.bufferedAmount grows)"]
  BUF -- "drains slowly" --> SLOW["slow client"]
  BUF -. "if it keeps growing" .-> OOM["server memory exhausted"]
A slow reader turns a fast producer into a growing memory queue

The danger is not one slow client — it is that nothing stops the queue from growing without bound. The fix is to measure the backlog and react when it is too big.

Every ws socket exposes ws.bufferedAmount — the number of bytes that have been queued by send but not yet flushed to the OS. It is your backpressure gauge. Before sending non-critical data, check it:

const HIGH_WATER_MARK = 1 << 20; // 1 MiB of pending bytes
function safeSend(ws, data) {
if (ws.bufferedAmount > HIGH_WATER_MARK) {
// This client is behind. Do not pile more on.
return false; // dropped
}
ws.send(data);
return true;
}

When bufferedAmount crosses your threshold, the client is not keeping up, and the right move is usually not “send anyway.”

What you do at the high-water mark depends on the data:

  • Drop. For a live feed (prices, telemetry, cursor positions), an old update is worthless once a newer one exists. Just skip the send. The client catches up on the next message.
  • Coalesce. Instead of sending every tick, keep only the latest state per client and send that when the buffer drains. Ten queued price updates collapse into one current price.
  • Disconnect. If a client stays hopelessly behind past a grace period, close the socket (e.g. code 1013, “try again later”). A reconnect with a fresh snapshot beats an ever-growing buffer.
  • Flow control. For data you cannot drop (a file transfer), stop producing until the buffer drains — let the slow consumer set the pace.

The unifying principle: bound the memory. Whatever you choose, the queue for any one client must have a ceiling.

ws.send accepts a callback that fires once the data has been handed off (or errored). It is a per-message signal you can use to pace a producer — only queue the next chunk after the previous one is flushed:

function pump(ws, chunks, i = 0) {
if (i >= chunks.length) return;
if (ws.readyState !== ws.OPEN) return;
ws.send(chunks[i], (err) => {
if (err) return; // socket gone
// Only schedule the next chunk once this one has drained.
if (ws.bufferedAmount < (1 << 20)) pump(ws, chunks, i + 1);
else setTimeout(() => pump(ws, chunks, i + 1), 50);
});
}

This is the WebSocket equivalent of respecting a stream’s drain event: never get more than a bounded amount ahead of the slowest consumer.

The demo below simulates a high-rate producer that checks bufferedAmount before each send. It drops updates when the (simulated) buffer is over the limit and reports how many it sent versus dropped — exactly the behaviour a real feed needs.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

A healthy client drains fast enough that bufferedAmount stays near zero and almost nothing is dropped. Throttle the consumer and you would see dropped climb while memory stays flat — which is the whole point. Note the clearInterval on close: an orphaned producer that keeps running after the client left is its own kind of leak. Protecting the server’s memory is the last responsibility of a WebSocket server, and the one that decides whether it survives a bad network day.

What does `ws.bufferedAmount` tell you?
Why is a slow client a memory risk for the server?
For a live price feed, which strategy fits a client that has fallen behind?