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.
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.
Reproduction Steps & Debugging Log Patterns
- 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
setIntervalat 1 s intervals loggingdc.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. - Run the pump with the
await waitForDrain(channel)line commented out and push a 200 MB source. WatchbufferedAmountclimb monotonically; the send-rate delta stays pinned near the link capacity while the queue absorbs everything else. - 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. - Restore the
await, rerun, and confirm the counter now oscillates between roughly 256 KiB and 1.06 MiB and never exceedsHIGH_WATER + CHUNK. - Cross-check with
pc.getStats(): thedata-channelreportβsbytesSentshould 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.
Common Implementation Mistakes
- Awaiting the event when the queue is already small.
bufferedamountlowis a downward crossing, not a level check. Await it below the threshold and the pump parks forever with a healthy channel and zero bytes moving. Always guard with thebufferedAmount <= bufferedAmountLowThresholdfast path. - Assigning
onbufferedamountlowinside the loop. Each iteration overwrites the previous handler, so an in-flight wait from an earlier iteration can never be resolved, and the last assignment leaks a closure over the finished chunk. UseaddEventListenerwith a matchingremoveEventListenerin acleanup(). - Not rejecting on
closeorerror. If the peer disconnects while you are parked, no drain event will ever arrive; the promise hangs, thefinallyblock never runs, and the reader lock is never released. Wire both events to a rejection. - Leaving the threshold at its default
0. The event then fires only on a fully empty queue, so the channel idles for one scheduling gap on every cycle and effective throughput drops measurably on high-RTT paths. Pick an explicit value around a quarter of your pause point. - Applying backpressure per chunk but sending oversized messages. The loop bounds the queue, not the message. A single 4 MiB
send()can breach the ceiling on its own and will also fail against peers advertising a smalla=max-message-size; keep the unit of work at 16 KiB, as detailed in Chunking Large File Transfers over Data Channels. - Reading
bufferedAmountas a network health metric. It measures your local queue only, not in-flight or unacknowledged bytes. A queue at zero on a badly congested path just means your producer is slow; the real signal lives in the transport stats described under Bandwidth Estimation & Congestion Control.
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.