Build a WebSocket channel

Bound the message flow

Protect both peers from producers that send faster than consumers can process.

A producer that outruns its consumer does not fail gracefully. It fills queues, grows memory, and eventually harms every other connection on the same process.

The browser WebSocket API exposes bufferedAmount for outbound data, but it does not give you automatic backpressure on inbound messages. You must set limits yourself.

Coalesce replaceable updates

Viewer counts change constantly; incident titles do not. We coalesce counts per incident id:

const pendingCounts = new Map()

function scheduleViewerCount(incidentId, count) {
  pendingCounts.set(incidentId, count)

  if (!flushTimer) {
    flushTimer = setTimeout(flushViewerCounts, 200)
  }
}

function flushViewerCounts() {
  for (const [incidentId, count] of pendingCounts) {
    broadcast({ type: 'event.viewer_count', incidentId, count })
  }
  pendingCounts.clear()
  flushTimer = null
}

Send five hundred count updates in one second. The board should emit a handful of frames, not five hundred.

Cap outbound queues per client

Track bytes waiting for a slow tab:

const MAX_BUFFERED = 512 * 1024

function safeSend(ws, frame) {
  if (ws.bufferedAmount > MAX_BUFFERED) {
    ws.close(4429, 'slow consumer')
    return false
  }

  ws.send(frame)
  return true
}

When bufferedAmount crosses half a megabyte, close with a documented code. The operator reconnects through the normal backoff path instead of dragging down the server.

Define a slow-client policy

Write it down: coalesce, drop, or disconnect. For incident state, never drop. For viewer counts, coalesce. For a client that cannot keep up at all, disconnect and let HTTP snapshot repair them.

Throttle one client in a test harness, burst updates, and watch server memory stay flat while critical incident events keep their order.

Measure before tuning

Log bufferedAmount periodically:

setInterval(() => {
  console.log('bufferedAmount', ws.bufferedAmount)
}, 1000)

If the number climbs without clearing during idle, you have a slow consumer even before you hit the cap.

Backpressure is not a library toggle. It is a product decision about which messages may be dropped, which must be queued, and when a client is too far behind to keep attached.

Try this on your own project: pick one replaceable metric, coalesce it, cap bufferedAmount, and prove memory stays bounded under burst load.

Lesson completed