Backpressure with bufferedAmountLowThreshold

RTCDataChannel.send() is fire-and-forget: it copies your bytes into a queue owned by the user agent, returns immediately, and tells you nothing about whether the link can actually carry them. This guide is part of the Data Channels & SCTP guide, and it solves exactly one problem β€” making a producer that generates data faster than SCTP can drain it wait, precisely and without polling, using bufferedAmount, bufferedAmountLowThreshold, and the bufferedamountlow event.

Context & Trade-offs

There are two independent clocks in every bulk send. The producer clock is your application: a Blob reader, a canvas encoder, a serializer. Reading a local file and slicing it into 16 KiB chunks runs at 200–400 MB/s on a modern laptop, because nothing in that path touches the network. The consumer clock is SCTP, and it moves at whatever the congestion controller currently permits β€” on a healthy residential path that is roughly 1–10 MB/s (8–80 Mbps), and on a relayed path it is lower still, since a TURN relay adds 20–40 ms one-way and the extra RTT shrinks the achievable window. The two clocks differ by a factor of 50 to 200. Every byte of that difference lands in bufferedAmount.

Nothing in the API stops it. bufferedAmount is a plain byte counter of application data queued but not yet handed to SCTP, and send() will happily push a 500 MB file into it in under three seconds. Three separate costs follow, in this order.

Memory. The queue is heap. A 200 MB transfer with no throttling is a 200 MB allocation spike in the tab, on top of whatever the source data already costs, and on a memory-capped mobile WebView it is a hard crash.

Latency. A standing queue is standing delay. 30 MB queued on a link draining at 1 MB/s means every byte you enqueue now is delivered 30 seconds from now. On an ordered channel that penalty applies to everything behind it, so a 20-byte β€œcancel transfer” control message sent on the same channel is also 30 seconds late. This is the argument for keeping control traffic on a second channel, the same separation described in Reliable vs Unreliable Data Channels for Game State.

Death. Chrome enforces an internal send-buffer ceiling of 16 MiB. A send() that would push past it raises an OperationError and Chrome tears the channel down rather than growing the queue further β€” readyState flips to 'closing' and your transfer is over with no partial-progress signal. Firefox has historically been more permissive and lets the queue grow until the content process runs out of memory, which is a worse failure because it takes the whole tab with it.

Send-queue growth with and without backpressure Left plot: bufferedAmount climbing steadily past the 16 MiB Chrome ceiling until the channel is closed. Right plot: bufferedAmount oscillating in a sawtooth between a 256 KiB low threshold and a 1 MiB high-water mark. No backpressure Threshold loop bytes queued 16 MiB ceiling channel closed queue grows at producer speed time 1 MiB high water 256 KiB threshold pause high, resume low, forever time
Without a pause condition the queue tracks the producer; with one it oscillates inside a fixed band.

Picking the band is the only real tuning decision. bufferedAmountLowThreshold defaults to 0, which means the event only fires when the queue is completely empty β€” the channel goes idle between every wake-up and you leave throughput on the table. Set it too close to your pause point and you wake up on nearly every chunk, paying an event dispatch and a microtask per 16 KiB. A low threshold at roughly one quarter of the high-water mark keeps both costs small.

High water Low threshold Resume events per 100 MiB Standing queue delay at 8 Mbps Verdict
64 KiB 16 KiB ~2,100 64 ms wake-up churn dominates CPU
1 MiB 256 KiB ~130 1.0 s good default for file transfer
8 MiB 2 MiB ~17 8.4 s control messages badly delayed
none default 0 0 unbounded OperationError, then closed

Note that the queue delay column, not the memory column, is what usually decides the number. 1 MiB buffered on an 8 Mbps uplink is about a second of latency for anything sharing that ordered stream β€” acceptable for a file, unacceptable if you also push presence or cursor updates down it.

Minimal Runnable Implementation

The whole pattern is one promise-returning helper plus a loop that awaits it. The helper must handle three cases the naive version gets wrong: the buffer is already below the threshold (no edge will ever fire, so resolve immediately), the channel closes mid-wait (reject instead of hanging forever), and repeated calls (register and remove listeners per wait rather than reassigning an on* property).

const CHUNK = 16 * 1024;             // 16 KiB: safe interoperable message size
const HIGH_WATER = 1024 * 1024;      // pause the producer above 1 MiB queued
const LOW_WATER = 256 * 1024;        // resume once SCTP has drained back to 256 KiB

const dc = pc.createDataChannel('bulk', { ordered: true });
dc.bufferedAmountLowThreshold = LOW_WATER; // MUST be set before the first wait

// Resolves the next time bufferedAmount crosses DOWN through the threshold.
function waitForDrain(channel) {
  // Fast path: the crossing already happened, so no event is coming. Without
  // this check a producer that pauses late deadlocks permanently.
  if (channel.bufferedAmount <= channel.bufferedAmountLowThreshold) {
    return Promise.resolve();
  }
  return new Promise((resolve, reject) => {
    const cleanup = () => {
      channel.removeEventListener('bufferedamountlow', onLow);
      channel.removeEventListener('close', onGone);
      channel.removeEventListener('error', onGone);
    };
    const onLow = () => { cleanup(); resolve(); };
    // A channel that dies while we are parked must reject, not hang.
    const onGone = () => { cleanup(); reject(new Error('channel closed while draining')); };
    channel.addEventListener('bufferedamountlow', onLow);
    channel.addEventListener('close', onGone);
    channel.addEventListener('error', onGone);
  });
}

// Drives a ReadableStream (file, encoder, generator) into the channel.
async function pump(channel, readable) {
  const reader = readable.getReader();
  let sent = 0;
  try {
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      for (let off = 0; off < value.byteLength; off += CHUNK) {
        if (channel.readyState !== 'open') throw new Error('channel not open');
        // Park BEFORE enqueuing, so the queue can never exceed HIGH_WATER + CHUNK.
        if (channel.bufferedAmount > HIGH_WATER) await waitForDrain(channel);
        channel.send(value.subarray(off, off + CHUNK));
        sent += Math.min(CHUNK, value.byteLength - off);
      }
    }
  } finally {
    reader.releaseLock(); // release even on abort so the source can be closed
  }
  return sent;
}

The subtlety that breaks most first attempts is that bufferedamountlow is an edge, not a level. The user agent fires it once, at the moment bufferedAmount decreases from a value above the threshold to a value at or below it. If you await the event while the queue is already small, nothing fires and the pump stops forever β€” which is precisely the fast path above. The mirror-image trap is setting bufferedAmountLowThreshold after the buffer has already drained past the new value; the crossing happened under the old threshold, so again there is no edge to catch. Set the threshold at channel creation and treat it as immutable.

Pause and resume state machine for a data channel writer An open channel enters the WRITING state which loops on send; crossing the high-water mark moves it to PARKED; the bufferedamountlow event returns it to WRITING; reader exhaustion moves it to a finishing state. open WRITING send(chunk) per 16 KiB PARKED awaiting drain promise bufferedAmount > 1 MiB bufferedamountlow fires loop close event: reject reader done: resolve two exits, never a hang
The writer only ever holds two states; both terminal edges settle the awaited promise.

Reproduction Steps & Debugging Log Patterns

  1. Open a channel to a peer on a throttled path β€” Chrome DevTools’ network conditioner set to roughly 8 Mbps up with 60 ms latency is representative β€” and start a setInterval at 1 s intervals logging dc.bufferedAmount, dc.readyState, and the delta in bytes sent since the last tick. One second is the same cadence used for media diagnostics, so it lines up with your other counters.
  2. Run the pump with the await waitForDrain(channel) line commented out and push a 200 MB source. Watch bufferedAmount climb monotonically; the send-rate delta stays pinned near the link capacity while the queue absorbs everything else.
  3. Let it run to failure. In Chrome you will see the queue stall just under 16 MiB and then the channel drop; catch the exception around send() to capture the exact error name.
  4. Restore the await, rerun, and confirm the counter now oscillates between roughly 256 KiB and 1.06 MiB and never exceeds HIGH_WATER + CHUNK.
  5. Cross-check with pc.getStats(): the data-channel report’s bytesSent should rise at a steady slope in both runs β€” identical throughput β€” proving the backpressure loop costs you nothing but memory savings.

The unthrottled run produces a log with an unmistakable shape:

// t+1s  buffered=1048576   sent/s=1.0MB  state=open
// t+3s  buffered=6291456   sent/s=1.0MB  state=open   // queue absorbing the delta
// t+9s  buffered=15990784  sent/s=1.0MB  state=open   // approaching the 16 MiB ceiling
// t+10s Uncaught (in promise) OperationError: Failure to send data
// t+10s buffered=0         sent/s=0      state=closing

The healthy run looks flat and boring, which is the point:

// t+1s  buffered=811008  sent/s=1.0MB  state=open  drains/s=1
// t+2s  buffered=344064  sent/s=1.0MB  state=open  drains/s=1
// t+3s  buffered=917504  sent/s=1.0MB  state=open  drains/s=2

If the queue is bounded but throughput is far below what the path should carry, the bottleneck is not your loop β€” it is the congestion window, and the diagnosis moves to the transport. The counters to read there are covered in Interpreting getStats() for Congestion Signals, and the dump-level view of the SCTP transport in Reading chrome://webrtc-internals Dumps.

Escalation timeline when backpressure is ignored Five points on a timeline: send loop starts, queue at thirty megabytes with added delay, heap growth, send throwing OperationError, and the channel closing. t = 0 s pump starts queued 0 B t = 2 s queued 2 MB delay +2 s t = 8 s queued 8 MB heap climbing t = 10 s 16 MiB ceiling send() throws t = 10.1 s state closing transfer lost every stage is invisible to send(), which never returns an error until the last one
Ignoring backpressure fails silently for ten seconds, then all at once.

Common Implementation Mistakes

FAQ

Does bufferedAmount include bytes SCTP has sent but not yet had acknowledged?

No. It counts only application data queued in the user agent that has not yet been handed to the SCTP layer for transmission, and it is decremented as soon as those bytes are passed down β€” not when the remote peer acknowledges them. That is why a queue reading zero is not proof of delivery, and why retransmission pressure under loss shows up as a slower drain rate rather than a higher bufferedAmount.

What high-water mark should I choose?

Work backwards from the latency you can tolerate on that channel, not from memory. Multiply your worst realistic uplink throughput by the acceptable added delay: at 8 Mbps and a one-second budget that is about 1 MiB, which is why 1 MiB paired with a 256 KiB threshold is the common default. Halve both numbers for a channel that also carries interactive control messages.

Do unreliable channels need backpressure too?

Yes, though the remedy differs. maxRetransmits: 0 skips retransmission but still rides SCTP flow control, so a fast producer still builds a queue. For superseding state the correct response is to drop your own stale outbound update when bufferedAmount is high rather than park and send it late β€” a delayed position sample has no value, while a delayed file chunk does.

Related: return to Data Channels & SCTP for the full reliability and buffering picture, and see the wider WebRTC Protocol Stack & Signaling Servers guide for the DTLS and ICE layers that carry every byte this loop enqueues.