skip to content

A browser page calls socket.send() many times a second on a WebSocket. On a slow network the tab's memory climbs and the UI stalls, yet send() never throws. What does socket.bufferedAmount tell you, and how do you use it to apply backpressure?

level: seniorimportance: should knowfreq 40%

answer

  1. send() never blocks and never reports slowness
  2. one read-only number, counted in bytes
  3. no drain event exists, so poll
  4. two thresholds beat one
  5. dropping beats queueing for live data

basics

~20 s

socket.bufferedAmount is the number of bytes passed to send() that the browser has not yet put on the network. send() never blocks or fails on a slow link, so an unbounded producer simply grows that queue; sampling bufferedAmount and pausing above a threshold is the only backpressure the API offers.

solid answer

~50 s

`send()` is fire-and-forget: it copies your data into a queue inside the browser and returns immediately, whatever the state of the network. `socket.bufferedAmount` exposes the size of that queue in bytes — it rises whenever you produce faster than the socket drains and falls back toward zero as bytes go out. There is no `drain` event and no promise to await, so backpressure means *polling*: sample `bufferedAmount` before each send, or on a timer or animation frame, and stop producing above a high-water mark, resuming below a low one. For live data, prefer dropping or coalescing stale updates over queueing them — a backlog of positions the user will never see is worse than a gap. Two traps: the queue lives inside the browser's socket implementation, so a JavaScript heap snapshot looks innocent; and `bufferedAmount` does not reset when the socket closes, so keep sending after a drop and it climbs forever.

code

javascript · 18 lines
javascript
const socket = new WebSocket('wss://example.com/telemetry');
const HIGH_WATER = 1 << 20; // 1 MiB queued -> stop producing
const LOW_WATER = 1 << 18;  // 256 KiB     -> resume producing
let paused = false;
let dropped = 0;

function publish(sample) {
  if (socket.readyState !== WebSocket.OPEN) return;

  if (paused && socket.bufferedAmount <= LOW_WATER) paused = false;
  if (!paused && socket.bufferedAmount > HIGH_WATER) paused = true;

  if (paused) {
    dropped += 1; // stale telemetry is worth less than a growing backlog
    return;
  }
  socket.send(JSON.stringify(sample));
}

go deeper

for a junior

Know that send() returns immediately and does not report whether data actually left the machine, and that bufferedAmount exists to show how many bytes are still waiting.

for a middle

Explain the mechanics: bufferedAmount counts queued bytes, drains to zero on a healthy socket, has no accompanying drain event, and does not reset when the connection closes.

for a senior

Demonstrate a real control loop — high and low water marks with hysteresis, sampled at a sensible point — and argue for dropping or coalescing stale messages rather than growing an application queue in front of a full socket queue.

for a principal

Own the flow-control contract for the realtime layer: which message classes are droppable, what staleness the product tolerates, and whether the protocol needs application-level acknowledgements so senders can slow down before memory becomes the symptom.

## Why send() cannot push back The WebSocket API is deliberately simple: `send()` returns `undefined`, immediately, always. It gives you no signal about whether the bytes made it onto the wire, no promise to await, and no error when the far end is slow. Internally the browser copies your payload into an outgoing queue and drains it as the network allows. On a fast link the queue is empty almost all the time and nobody notices. On a congested mobile connection, or with a peer that has stopped reading, the drain rate collapses while your producer keeps its pace, and the queue grows without limit. The symptoms are distinctive. Memory climbs, but a JavaScript heap snapshot shows nothing unusual, because the queued bytes are held by the browser's networking layer rather than as reachable JavaScript objects. The main thread stalls in bursts, because every `send()` still costs serialisation — the `JSON.stringify` or the typed-array build that produced the payload runs whether or not the bytes will ever leave. And the data that finally arrives is minutes stale, because the peer is receiving a backlog rather than the present. ## What bufferedAmount reports `socket.bufferedAmount` is a read-only number: the count of bytes passed to `send()` that have not yet been transmitted to the network. Some details matter in practice: - It counts **bytes**, not messages and not string length. A string is measured after UTF-8 encoding, so multi-byte characters count for more than `str.length`. - It returns to zero once the queue drains, so a healthy socket hovers near zero and spikes only under bursts. - It **does not** reset when the connection closes. Since `send()` on a closed socket is a silent no-op that still accounts for the bytes, a loop that keeps sending after a drop shows `bufferedAmount` climbing forever — which is a useful bug signature, and a reason to pair every threshold check with a `readyState` check. ## Building the gate Because there is no drain event, backpressure is a sampled control loop with hysteresis — one threshold to stop at and a lower one to resume at, so you do not oscillate on every message: ```js const HIGH_WATER = 1 << 20; // 1 MiB const LOW_WATER = 1 << 18; // 256 KiB let paused = false; function trySend(payload) { if (socket.readyState !== WebSocket.OPEN) return false; if (paused) { if (socket.bufferedAmount > LOW_WATER) return false; paused = false; } if (socket.bufferedAmount > HIGH_WATER) { paused = true; return false; } socket.send(payload); return true; } ``` Where you sample depends on the producer. If the data comes from user input or a timer, checking inside `trySend` is enough. If it comes from an animation loop, `requestAnimationFrame` is a natural sampling point and keeps the check aligned with the frame budget. What you must not do is spin waiting for the number to fall: the queue only drains between tasks, so a busy loop watching `bufferedAmount` blocks the very turn of the event loop that would have made progress. ## Drop, coalesce, or queue The interesting design question is what happens to the message you refused to send. - **Drop** it, for sampled telemetry, mouse positions, or a live sensor feed. Losing intermediate points is invisible; delivering them late is not. - **Coalesce**: keep only the newest value per key and send that when the gate opens. A cursor position, a document's current state, or a per-symbol price quote all compress naturally this way, and a coalescing buffer has a fixed upper bound. - **Queue** it in your own bounded array, for messages that must all arrive — chat lines, edits, commands. Bounded is the operative word: an unbounded application queue in front of a full socket queue has simply moved the leak up a layer. The honest framing for an interview is that WebSockets give you no flow control at the API level, so the application must decide which of its messages are droppable. That is a product decision as much as a technical one. ## Related mistakes Moving the socket into a Web Worker is a real win for the serialisation cost — the `JSON.stringify` no longer competes with rendering — but it creates no backpressure whatsoever. The queue behaves identically; you have only moved the producer off the main thread, so the UI stops stalling while the memory growth and the staleness continue unchanged. Similarly, batching many small messages into one larger one reduces per-message overhead but does not change the fundamental arithmetic: if you produce more bytes per second than the link carries, the queue grows. Only refusing to produce fixes that, which is precisely what a `bufferedAmount` gate is for.

  • Does moving the WebSocket into a Web Worker solve this problem?
    Only the CPU half of it. Serialising payloads off the main thread stops the UI from stalling, but the browser's send queue behaves exactly the same, so memory still grows and the data still arrives stale. Backpressure has to come from the producer refusing to produce; a worker changes where the work happens, not whether it is throttled.
  • Why does bufferedAmount keep growing after the socket has closed?
    Because `send()` on a closed socket does not throw — the data is discarded, but the byte count is still accounted for and the property is specified not to reset on close. A steadily climbing `bufferedAmount` with no traffic is therefore a reliable signature of code that kept sending into a dead socket without checking `readyState`.
  • How would you pick a high-water mark for a particular application?
    Work backwards from acceptable latency, not from memory. Divide the tolerable staleness by the byte rate you can realistically sustain: a queue of one megabyte over a link doing a hundred kilobytes a second is ten seconds of lag. Set the mark where that lag stops being acceptable, then set the resume threshold well below it to avoid thrashing.

saying these in an interview costs you the question

  • Believes send() blocks or fails when the network is slow
  • Expects a drain event or a promise from send()
  • Reads bufferedAmount in a busy loop waiting for zero
  • Thinks a Web Worker gives the socket backpressure
  • Queues unbounded live updates instead of dropping stale ones

context