Reliability & Flow Control
One transport, several contracts
A chat message must arrive. A position update from 16 milliseconds ago must not arrive late and overwrite a newer one. Both travel over the same RTCDataChannel. The transport underneath (SCTP over DTLS over UDP) can serve either contract, but only if you tell it which one you want.
The default channel behaves like TCP: every byte arrives, in order. That contract is correct for chat, control messages, and file payloads. It is wrong for real-time game state, where a retransmitted stale packet costs latency and buys you nothing.
This article covers the full reliability spectrum exposed by RTCDataChannelInit, how each mode maps to a use case, how head-of-line blocking stalls the wrong channels, and (the part most code gets wrong) how to drive a sender that respects backpressure instead of overrunning the SCTP send buffer.
If you have not yet read the data channel basics, start there. This page assumes you know what a channel is and how to open one.
The reliability model
SCTP gives each channel two independent dials.
Reliability decides whether lost packets are retransmitted. Full reliability retransmits until delivery succeeds. Zero reliability never retransmits. Partial reliability retransmits up to a bound: either a retransmit count or a time budget.
Ordering decides whether the receiver waits for earlier packets before delivering later ones. Ordered delivery preserves send order. Unordered delivery hands each message to the application the moment it arrives.
The two dials are orthogonal. You can have unordered-but-reliable, or ordered-but-unreliable, or any other combination. The combination you pick is fixed at channel creation and cannot change afterward.
What sits under the channel
The settings make sense only once you know what they configure. A data channel is not a raw socket. It is a stream within an SCTP association, and that association runs inside a DTLS session over UDP.
From the bottom up:
- UDP carries datagrams with no delivery guarantee and no ordering. It is the substrate the STUN-negotiated path hands you after ICE picks a candidate pair.
- DTLS wraps UDP in the datagram form of TLS. Every byte a data channel sends is encrypted and authenticated before it leaves the machine. There is no unencrypted mode; confidentiality is not optional.
- SCTP runs inside DTLS and provides the part UDP lacks: multiple ordered or unordered streams, segmentation, acknowledgements, congestion control, and the retransmit machinery you are tuning.
RTCDataChannel (your messages)
└── SCTP (streams, retransmit, congestion control)
└── DTLS (encryption, authentication)
└── UDP (datagrams, best effort)
The reliability and ordering dials are SCTP features. maxRetransmits and maxPacketLifeTime map onto SCTP's partial reliability extension (RFC 3758), which lets the sender abandon a message after a count or a deadline. ordered: false selects SCTP's unordered delivery for that stream. Browsers expose these through RTCDataChannelInit and translate them into the SCTP stream configuration during the handshake.
One association carries every channel on a peer connection. Each createDataChannel call opens a new SCTP stream inside the same association, sharing one DTLS session and one congestion-control state. That sharing is why splitting traffic across channels isolates head-of-line blocking but does not give each channel its own bandwidth; they compete for the same congestion window.
The reliability spectrum
Every mode is a setting of two or three properties on the RTCDataChannelInit object passed to createDataChannel.
const channel = pc.createDataChannel(label, init);
Reliable and ordered (the default)
Pass no reliability options at all and you get TCP-like behavior. Every message arrives, in send order.
// Reliable + ordered. ordered defaults to true.
const chat = pc.createDataChannel('chat');
SCTP retransmits any lost segment until the peer acknowledges it. The receiver buffers out-of-order segments and delivers them only once the gap is filled. From the application's view, send(a); send(b); send(c) always surfaces as a, b, c on the other side, complete.
Use this for anything where correctness beats latency: chat, lobby and control messages, scoreboard syncs, and the metadata frames that bracket a file transfer. The library's signaling channel in src/salon/handshake.js opens a default channel for exactly this reason: the handshake must not lose a message.
Fully unreliable (maxRetransmits: 0)
Set maxRetransmits to 0 and SCTP never retransmits. A lost packet is gone. Pair it with ordered: false and a late packet is delivered immediately rather than held back.
// Fully unreliable + unordered. Fire and forget.
const state = pc.createDataChannel('state', {
ordered: false,
maxRetransmits: 0,
});
This is the UDP-like extreme. It is correct for data that supersedes itself: a player position, a camera orientation, a cursor coordinate. If one update is lost, the next one (arriving 16 to 50 milliseconds later) replaces it anyway. Retransmitting the old one would only add latency and waste bandwidth.
The library's peer-pool.js accepts a channelInit argument precisely so a game can open this kind of channel for state broadcast while keeping the control channel reliable.
Partially reliable by retransmit count (maxRetransmits: N)
Set maxRetransmits to a positive integer and SCTP retries a lost packet up to N times, then gives up.
// Retransmit up to twice, then drop.
const events = pc.createDataChannel('events', {
ordered: false,
maxRetransmits: 2,
});
This sits between the two extremes. Use it for data that is worth a couple of attempts but not worth blocking on: non-critical game events, hit sparks, audio cue triggers. Two retries on a healthy link recover most transient losses; a packet still missing after that is probably stale.
Partially reliable by time (maxPacketLifeTime: ms)
Set maxPacketLifeTime to a millisecond budget and SCTP retransmits a lost packet only within that window. After the budget expires, it drops the packet.
// Try to deliver for up to 300ms, then drop.
const telemetry = pc.createDataChannel('telemetry', {
ordered: false,
maxPacketLifeTime: 300,
});
This expresses reliability as a deadline rather than a count. It fits data with a hard freshness limit: a voice-activity flag, a 250-millisecond animation trigger, a sensor reading that is meaningless once it ages out. The transport keeps trying until the deadline, then stops; you never receive data that is already too old to use.
Count and time express the same idea (bounded effort) through different units. Pick the count form when "how many tries" is the natural budget, such as a fixed two attempts for a game event. Pick the time form when "how long is this still useful" is the natural budget, such as a snapshot that expires after one frame. On a link with a known round-trip time the two are roughly interchangeable: two retransmits on an 80-millisecond link is about a 240-millisecond window. They diverge when latency varies, because the count form keeps retrying regardless of how long each retry takes, while the time form caps total effort no matter how many retries fit inside it. For anything with a real deadline, prefer the time form: it bounds the thing you actually care about.
maxRetransmits and maxPacketLifeTime are mutually exclusive. Set at most one. Supplying both throws a TypeError at createDataChannel. Setting neither (with the defaults) gives you full reliability.
Unordered, independent of reliability
ordered: false is a separate switch. It tells the receiver to deliver each message the moment it is complete, without waiting for earlier messages.
You can combine it with full reliability:
// Reliable but unordered: every message arrives, order not guaranteed.
const tasks = pc.createDataChannel('tasks', { ordered: false });
This guarantees delivery but not sequence. It suits independent items where each message is self-contained: discrete events that carry their own identity, or work units that can be applied in any order. You trade ordering for lower delivery latency under loss.
The modes at a glance
| Mode | ordered |
Reliability option | Delivery contract | Use for |
|---|---|---|---|---|
| Reliable ordered (default) | true |
none | All messages, in order | Chat, control, scores, file metadata |
| Reliable unordered | false |
none | All messages, any order | Independent self-contained events |
| Partial by count | false |
maxRetransmits: N |
Up to N retries, then drop | Non-critical game events |
| Partial by time | false |
maxPacketLifeTime: ms |
Retry within budget, then drop | Time-sensitive triggers, telemetry |
| Fully unreliable | false |
maxRetransmits: 0 |
No retry, fire and forget | Position, cursor, camera state |
The mapping is the point. Pick the mode from the data's contract, not the other way around. Ask: if this message is lost, do I want it back? If a later message arrives first, do I want to wait? The answers select the row.
Head-of-line blocking
Head-of-line blocking is the reason ordering and reliability matter beyond a single channel's correctness.
On an ordered reliable channel, the receiver delivers messages strictly in sequence. If message 5 is lost, messages 6, 7, and 8 sit in the receive buffer (fully arrived), but the application cannot see them until message 5 is retransmitted and received. One lost packet stalls everything behind it.
For chat this is fine: you want message 5 before message 6 anyway. For a 60 Hz position stream it is a disaster. A single dropped packet freezes the remote player until the retransmit lands, even though three newer positions already arrived.
Two settings avoid the stall:
ordered: false removes the wait. Messages 6, 7, and 8 reach the application as soon as they arrive; message 5 is delivered late, out of place, or (if unreliable) never.
maxRetransmits: 0 removes the retransmit. Nothing waits for the lost packet because nothing tries to recover it.
A second, structural defense: split traffic across channels. SCTP multiplexes independent streams over one association, and head-of-line blocking is per-stream, not per-association. Put reliable control traffic on one channel and unreliable state on another. A lost state packet then cannot stall a queued chat message, and vice versa. This is why a game opens at least two channels rather than overloading one. See the protocol layer for how the library routes messages once they arrive.
A worked timeline
Take a 60 Hz position stream. The host sends a position every 16 milliseconds. Packet 5 is lost in transit. Packets 6, 7, and 8 arrive on schedule.
On an ordered reliable channel the receiver holds 6, 7, and 8 in its buffer. The application sees nothing new. SCTP detects the gap, requests packet 5 again, and waits a round trip, say 80 milliseconds. Packet 5 finally arrives. Only then does the application receive 5, 6, 7, and 8 in a burst. The remote player froze for 80 milliseconds, then teleported through four positions. The frozen frame showed a position five updates old.
On an unordered unreliable channel the receiver delivers 6 the instant it arrives, then 7, then 8. Packet 5 is never re-requested and never shows up. The application missed one position out of four and never noticed, because each new position overwrote the last. The remote player moved smoothly.
Same loss, opposite outcomes. The ordered channel turned one dropped packet into an 80-millisecond stall plus a visual jump. The unordered unreliable channel absorbed it. This is the single most important reason to match the mode to the data.
A multi-channel game in practice
A two-player game built on the library opens distinct channels for distinct contracts, all over one peer connection.
// Control: lobby, ready-up, propose/accept, game-over. Must not drop.
const control = pc.createDataChannel('control');
// State: 60 Hz authoritative snapshots. Stale frames are worthless.
const state = pc.createDataChannel('state', {
ordered: false,
maxRetransmits: 0,
});
// Events: hits, pickups, score deltas. Worth a couple of tries.
const events = pc.createDataChannel('events', {
ordered: false,
maxRetransmits: 2,
});
The control channel carries the hub's propose/accept dance and the final score. Losing a game-over message would leave a player stuck, so it is reliable and ordered. The state channel carries the simulation snapshot the authoritative host broadcasts every tick; a lost snapshot is replaced by the next one, so it is unreliable and unordered. The events channel sits between them: a missed hit spark is tolerable but worth two retries, and order between independent events does not matter.
peerPool in src/salon/peer-pool.js takes a channelInit argument so the host can open the state channel with these options per guest. The multiplayer model describes which data belongs on which channel for the authoritative-host and hidden-state patterns.
Throughput against latency
Reliability and ordering are also a throughput-versus-latency choice.
A reliable ordered channel maximizes correctness at the cost of tail latency. Under loss, the 99th-percentile delivery time spikes because of retransmits and head-of-line waiting, even though throughput on a clean link is high.
An unreliable unordered channel minimizes latency. Every message takes the shortest path to the application and is never held back. Throughput on a lossy link is effectively higher for useful data, because bandwidth is not spent re-sending packets that are already obsolete.
There is no universally correct setting. A file wants the reliable channel's throughput and tolerates its latency. A game's position stream wants the unreliable channel's latency and tolerates its loss, because the next update repairs the gap. The multiplayer state model leans on exactly this: send state often and unreliably, and let frequency cover for loss.
Congestion control still applies
Unreliable does not mean unmetered. SCTP runs congestion control on the whole association regardless of any single channel's reliability mode. It maintains a congestion window (a cap on how much unacknowledged data may be in flight) and shrinks that window when it detects loss.
The consequence is subtle. An unreliable channel never retransmits, but its sends still consume the congestion window, and the window still contracts under loss because SCTP reads loss as a congestion signal. So a lossy link slows even your fire-and-forget stream: the transport throttles to avoid making congestion worse. You cannot opt out of that by setting maxRetransmits: 0. You only opt out of the retransmits, not the rate limiting.
This is another reason backpressure matters for every channel. When the window contracts, sends queue in the buffer instead of going out, and bufferedAmount climbs. A sender that ignores it keeps piling data onto a link the transport has already decided to slow down.
Flow control and backpressure
Reliability decides what happens to packets in flight. Flow control decides what happens to packets you have not sent yet. Get this wrong and the mode you chose stops mattering, because the bottleneck moves into your own send buffer.
The send buffer fills
channel.send(data) does not put bytes on the wire. It hands them to the SCTP send buffer. SCTP drains that buffer at the rate the link and congestion control allow. If you call send faster than the link drains, the buffer grows.
The buffer is not infinite. When it is full, behavior depends on the browser: older or stricter implementations throw, and the channel can be torn down. A naive loop that pushes a large file or a fast state stream without checking will overrun it.
The failure is easy to trigger and easy to miss. A loop that reads a file and calls send for every chunk back to back runs in microseconds. The link drains at megabytes per second. The buffer fills in a single synchronous burst, long before the first byte leaves the machine. On a fast generator and a slow link, the gap between produce rate and drain rate is several orders of magnitude. Without a brake, the buffer wins.
channel.bufferedAmount reports how many bytes are queued in the send buffer but not yet sent. It is your one window into how far ahead of the link you are running.
console.log(channel.bufferedAmount); // bytes queued, not yet on the wire
Polling bufferedAmount is the wrong reflex
The pattern in a lot of sample code is to check bufferedAmount against a threshold and drop the message if it is over:
// Crude: drops data instead of pausing. Fine for fire-and-forget state,
// wrong for a file you must deliver in full.
function sendUpdate(data) {
if (channel.bufferedAmount > 65536) return;
channel.send(data);
}
This is acceptable for an unreliable state stream, where dropping a stale update is the correct outcome anyway. It is wrong for anything you must deliver in full, because it silently discards data. For a file transfer, dropping a chunk corrupts the result.
bufferedAmountLowThreshold and onbufferedamountlow
The right mechanism is event-driven, not poll-and-drop. Two properties make it work.
channel.bufferedAmountLowThreshold is a byte level you set. When bufferedAmount falls to or below it, the channel fires a bufferedamountlow event. The default threshold is 0, which fires only when the buffer fully drains, usually too late to keep the pipe full.
Set the threshold to a value below your high-water mark. Then: send until bufferedAmount crosses the high mark, stop, and wait for bufferedamountlow to tell you the buffer drained back to the threshold before sending more.
const HIGH = 1 << 20; // 1 MiB: stop sending above this
const LOW = 1 << 18; // 256 KiB: resume when buffer drains to here
channel.bufferedAmountLowThreshold = LOW;
A backpressure-aware sender
This sender walks a sequence of chunks and never overruns the buffer. It pauses when bufferedAmount reaches the high-water mark and resumes from the bufferedamountlow event. No data is dropped; the loop only slows to match the link.
// Backpressure-aware sender. Resolves when every chunk is queued.
// Use for files and any payload that must arrive in full.
function sendAll(channel, chunks) {
const HIGH = 1 << 20; // pause above 1 MiB queued
const LOW = 1 << 18; // resume at 256 KiB queued
channel.bufferedAmountLowThreshold = LOW;
return new Promise((resolve, reject) => {
let i = 0;
// Wake the pump when the buffer has drained to the low threshold.
const resume = () => pump();
channel.addEventListener('bufferedamountlow', resume);
function pump() {
try {
while (i < chunks.length) {
if (channel.readyState !== 'open') {
throw new Error('channel closed mid-transfer');
}
// Above the high-water mark: stop. The bufferedamountlow
// event will call pump() again once the link catches up.
if (channel.bufferedAmount >= HIGH) return;
channel.send(chunks[i]);
i++;
}
// All chunks queued. Stop listening and finish.
channel.removeEventListener('bufferedamountlow', resume);
resolve();
} catch (err) {
channel.removeEventListener('bufferedamountlow', resume);
reject(err);
}
}
pump();
});
}
The shape is the important part. The loop sends until either it runs out of chunks or it hits the high-water mark. On the high-water mark it returns without finishing: the bufferedamountlow event re-enters pump once SCTP has drained the buffer down to LOW. Two thresholds, not one, give hysteresis: the gap between HIGH and LOW stops the sender from thrashing between paused and resumed on every packet.
sendAll resolves only when every chunk is queued, so a caller can await it before sending an end-of-transfer marker. This is the back half of the chunking strategy: chunking decides message size, backpressure decides send rate.
Set bufferedAmountLowThreshold below your high-water mark, never equal to it. If LOW equals HIGH, the channel ping-pongs between paused and resumed on every single packet and throughput collapses. A gap of two to four times keeps the pipe full without thrashing.
Both peers must agree on the channel
A reliability mode is set once, at creation, on the side that calls createDataChannel. The other side normally receives the channel through the ondatachannel event, already configured; it does not get to choose the mode. So decide the contract on the opening side and let the peer accept it.
The library follows this in src/salon/peer.js: setupPeer wires pc.ondatachannel to hand the incoming channel to onChannel, and attachChannel binds the receive, open, and close handlers. The guest never reconfigures reliability; it uses whatever the host opened. If both sides need to open channels, agree on labels up front so neither side is surprised by an unexpected channel, and remember that opening more channels does not buy more bandwidth; they all share the one association's congestion window.
There is also a negotiated mode (negotiated: true with an explicit id) where both sides create the channel independently and skip ondatachannel. It avoids one round trip of in-band negotiation, but both sides must then pass identical options, including the reliability settings. Mismatched options on a negotiated channel produce two endpoints that disagree about the contract. The in-band default (open on one side, receive on the other) is safer because the configuration travels with the channel.
Why this matters for real-time state too
A fire-and-forget state stream uses the same signal differently. Instead of pausing and resuming, it checks bufferedAmount before each send and skips the update when the buffer is backing up. Skipping is correct here: a position you could not send 50 milliseconds ago is already stale, and the next tick will produce a fresher one. Backpressure for files means slow down; backpressure for state means drop the old frame.
// Backpressure for state: skip the tick instead of queuing it.
const STATE_CAP = 64 * 1024; // 64 KiB queued is already too far behind
function broadcastState(channel, snapshot) {
if (channel.readyState !== 'open') return;
// Buffer backing up? Skip this snapshot; the next tick is fresher.
if (channel.bufferedAmount > STATE_CAP) return;
channel.send(snapshot);
}
The difference is the response to a full buffer. The file sender stops and waits, preserving every byte. The state sender drops the frame and moves on, preserving freshness. Both read the same bufferedAmount; they disagree on what the right reaction is because their data has different contracts. This is the reliability decision from the top of the article, applied to the send side instead of the wire.
The receiver side
Backpressure protects the sender. The receiver has its own concern: a channel can deliver onmessage events faster than the application processes them, and there is no bufferedAmount on the receive side to throttle from.
If your message handler does heavy work (decoding, drawing, allocating), keep it short and defer the rest. Hand the payload to a queue and process it on your own schedule, off the onmessage callback. For an ordered reliable file stream this also keeps reassembly correct: messages arrive in order, so appending each chunk to a growing buffer in arrival order reconstructs the original. For an unordered channel the receiver must carry a sequence number in each message and reorder on its own, because the transport no longer does it. That framing is the subject of chunking.
Maximum message size and fragmentation
One more limit shapes how you send: a single send call has a maximum size.
pc.sctp.maxMessageSize reports the largest message the negotiated SCTP association accepts. Modern browsers negotiate large values, but the safe interoperable floor is well under that. Sending a message larger than the peer's maxMessageSize fails, and historically some implementations dropped or errored on messages over roughly 64 KiB.
const max = pc.sctp.maxMessageSize; // largest single send, in bytes
The transport does fragment large messages internally over SCTP, but you should not rely on that for interoperability or for backpressure accounting. Instead, fragment in the application: split a large payload into chunks comfortably under the safe limit (16 KiB is a common, widely interoperable choice) and reassemble on the receiver. Smaller chunks also give the backpressure loop finer control, because bufferedAmount updates per chunk rather than jumping by a megabyte at a time.
The 16 KiB figure is a deliberate compromise, not a hard rule. Smaller chunks mean more per-message overhead (each send carries SCTP and DTLS framing) and more onmessage events to process, which costs CPU on both sides. Larger chunks reduce overhead but coarsen backpressure: with megabyte chunks, bufferedAmount lurches in megabyte steps and the high-water mark loses precision, and you risk exceeding a conservative peer's maxMessageSize. 16 KiB stays well under every interoperable limit, keeps per-message overhead modest, and lets bufferedAmount track the link closely enough for the pause-and-resume loop to work smoothly. If you control both ends and have measured a higher maxMessageSize, you can raise it; for a transfer to an unknown peer, stay conservative.
This is the bridge to chunking, which covers the framing, sequencing, and reassembly that turn a stream of 16 KiB chunks back into a file.
Recap
You picked a reliability mode from the data's contract:
- Reliable ordered for chat, control, and file payloads: correctness over latency.
- Unreliable unordered for position and cursor state: latency over correctness, frequency covers loss.
- Partial by count or by time for data worth a bounded effort but not a stall.
You avoided head-of-line blocking by setting ordered: false or maxRetransmits: 0 on latency-sensitive channels, and by splitting reliable and unreliable traffic across separate channels so a loss on one cannot stall the other.
You drove the sender with backpressure: a high-water mark to pause, a lower bufferedAmountLowThreshold to resume on the bufferedamountlow event, and per-chunk sizing under maxMessageSize. Files pause and resume; state drops stale frames.
Going further
- RTCDataChannel basics: opening channels and the SCTP stack.
- The protocol layer: routing messages once they arrive.
- P2P multiplayer: the state-sync model that relies on unreliable channels.
- Binary chunking: framing and reassembly above the backpressure loop.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
TypeError at createDataChannel |
Both maxRetransmits and maxPacketLifeTime set |
Set at most one |
| Remote entity freezes on packet loss, then jumps | Head-of-line blocking on an ordered reliable channel | Set ordered: false; for state also maxRetransmits: 0 |
| Channel closes during a large send | Send buffer overrun, no backpressure | Gate sends on bufferedAmount; resume from bufferedamountlow |
| Throughput collapses under backpressure | bufferedAmountLowThreshold equal to the high-water mark |
Set the low threshold well below the high mark for hysteresis |
send fails or errors on a large payload |
Message exceeds peer maxMessageSize |
Fragment to ~16 KiB chunks; check pc.sctp.maxMessageSize |
| Reliable mode chosen but data is always stale on arrival | Wrong mode for self-superseding data | Switch to unreliable unordered; let frequency cover loss |
bufferedamountlow never fires |
Threshold at default 0; buffer never fully drains under load |
Set bufferedAmountLowThreshold to a non-zero level |