Data Channels & SCTP
WebRTC is widely associated with audio and video, but the same peer connection also carries arbitrary application data over RTCDataChannel. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it details how data channels are transported over SCTP-over-DTLS, how to tune ordering and reliability per channel, and how to manage send-side buffering so a busy channel never stalls the connection or exhausts memory. The implementation goal is a production data channel that delivers chat, file transfer, or game state with the exact latency and delivery semantics each payload class requires.
A data channel is not a raw socket. It is a logical stream multiplexed onto a single Stream Control Transmission Protocol (SCTP) association, which itself runs inside the encrypted DTLS session that WebRTC already established for media. That layering is what gives data channels their unusual flexibility: SCTP exposes per-stream reliability and ordering controls that TCP cannot, while inheriting DTLS confidentiality and ICE/UDP NAT traversal for free. Understanding the stack is the prerequisite for every tuning decision below.
SCTP supplies the features TCP lacks: multiple independent streams in one association (so head-of-line blocking on a file-transfer channel never delays a chat channel), and a partial-reliability extension (RFC 3758) that lets each message be abandoned after a retransmit count or a time budget. WebRTC surfaces these knobs through three createDataChannel options — ordered, maxRetransmits, and maxPacketLifeTime — plus negotiated/id for out-of-band setup. The rest of this guide maps those options to concrete delivery guarantees and shows how to wire up backpressure so high-throughput channels stay healthy.
Why does this layering matter for an application developer? Because every property you care about — confidentiality, ordering, congestion behavior, NAT traversal — is owned by a different layer, and the failure modes you debug live at the boundaries between them. A channel that never reaches open is almost always an ICE or DTLS problem one or two layers down, not an RTCDataChannel bug. A channel that delivers but stalls under loss is an SCTP reliability-mode choice. A channel that exhausts memory is an application-layer backpressure miss. Keeping the stack diagram in mind tells you which layer to instrument first when something goes wrong, which is why the rest of this guide repeatedly ties each tuning decision back to the layer that enforces it.
SCTP also brings a property TCP cannot: it negotiates a single congestion-controlled association and then carries every channel inside it. That means a video call already running over the same RTCPeerConnection shares the path budget with your data channels, and a flood of file-transfer bytes can starve a low-rate control channel even though they are logically separate streams. Treat the association as a shared resource: rate-limit bulk transfers, keep control traffic on its own channel, and never assume an “unreliable” channel is free of congestion control just because it skips retransmission.
Step 1 — Open a channel and choose its delivery semantics
A data channel is created on either peer with pc.createDataChannel(label, options). The options object selects the reliability profile, and the choice is permanent for the channel’s lifetime — you cannot change ordered or the retransmit policy after creation, so model your payloads first.
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// Fully reliable + ordered (TCP-like): chat, signaling-style control, file transfer
const chat = pc.createDataChannel('chat', { ordered: true });
// Unreliable + unordered (UDP-like): high-frequency state that supersedes itself
const state = pc.createDataChannel('state', {
ordered: false, // delivery order is not preserved across messages
maxRetransmits: 0 // never retransmit; a lost message is simply dropped
});
// Partially reliable by time budget: tolerate brief loss recovery, then give up
const telemetry = pc.createDataChannel('telemetry', {
ordered: false,
maxPacketLifeTime: 100 // ms; SCTP stops retransmitting after this window
});
The three reliability modes are mutually exclusive in spirit: set at most one of maxRetransmits or maxPacketLifeTime. Omitting both yields full reliability; setting either makes the channel partially reliable. maxRetransmits: 0 is the special case that means “send once, never retransmit,” which combined with ordered: false produces true datagram semantics. The table below summarizes the matrix.
| Goal | ordered | reliability option | Resulting semantics |
|---|---|---|---|
| Chat, control, file chunks | true |
none | Reliable, in-order (TCP-like) |
| Out-of-order reliable events | false |
none | Reliable, may arrive reordered |
| Positional / cursor state | false |
maxRetransmits: 0 |
Unreliable datagram (UDP-like) |
| Time-bounded telemetry | false |
maxPacketLifeTime: 100 |
Best-effort within 100 ms |
For a worked decision on positional versus event traffic in interactive applications, see Reliable vs Unreliable Data Channels for Game State.
The mental model that prevents most mistakes here is to ask, for each payload, “if this specific message is lost, do I want it resent or do I want the next message instead?” Chat lines, file chunks, and state-machine transitions must be resent — losing one corrupts everything after it, so they need full reliability. A cursor position, a player coordinate, or a sensor reading is superseded by the next sample within milliseconds, so resending it only delays the fresher value behind it. Ordering is a second, independent axis: ordered: false lets SCTP deliver a later message before an earlier one finishes recovering, which is exactly what you want for superseding state and exactly what you must avoid for a byte stream you will reassemble in sequence. Decide reliability and ordering separately, per channel, and the rest of the configuration follows mechanically.
Step 2 — Wire up the channel lifecycle and message handling
A data channel has its own readiness state independent of the peer connection. Messages can only be sent once readyState === 'open', which fires after the SCTP association completes — typically a few hundred milliseconds after the DTLS handshake. Attach handlers before that point so you never miss the open event.
chat.binaryType = 'arraybuffer'; // 'blob' is the default in Chrome; 'arraybuffer' avoids async reads
chat.onopen = () => {
// SCTP association is up; safe to send now
chat.send(JSON.stringify({ type: 'hello' }));
};
chat.onmessage = (event) => {
// event.data is string for text, ArrayBuffer/Blob for binary per binaryType
handleMessage(event.data);
};
chat.onclose = () => console.log('channel closed; SCTP stream torn down');
chat.onerror = (e) => console.error('data channel error', e.error);
// The remote peer receives channels it did NOT create via ondatachannel
pc.ondatachannel = (event) => {
const incoming = event.channel; // label, ordered, id all mirror the creator's options
incoming.binaryType = 'arraybuffer';
incoming.onmessage = (e) => handleMessage(e.data);
};
For an in-band channel (the default), one peer calls createDataChannel and the other receives it through ondatachannel once SDP negotiation completes — the channel’s existence is announced inside the SCTP handshake. This requires a normal offer/answer exchange, so an in-band channel created after the connection is already established triggers renegotiation. Plan channel creation before createOffer when you can, to avoid an extra round trip over your signaling transport.
The first data channel created on a fresh peer connection is special: creating it adds the m=application line and the SCTP parameters to the SDP, so it must exist before you generate the initial offer or you will renegotiate immediately. Subsequent in-band channels reuse the existing association and announce themselves cheaply over SCTP without new SDP, but if you opened the connection with media only and add your first data channel later, expect a renegotiation. The practical rule: if you know you will use data channels at all, create at least one (even a placeholder control channel) before the first createOffer. This keeps the SDP stable and avoids surprising glare with media renegotiation.
Step 3 — Use negotiated channels to skip in-band setup
When both peers already agree on the channel layout (a fixed protocol with known channels), open each side independently with negotiated: true and a matching id. This avoids the ondatachannel event and the renegotiation that in-band creation can trigger — both endpoints construct the channel locally and SCTP binds them by stream id.
// Both peers run this with identical id values; no ondatachannel fires.
const control = pc.createDataChannel('control', {
negotiated: true,
id: 0, // must match on both ends; assign even ids if you also have media
ordered: true
});
Rules that bite in production: the id must be identical on both peers and unique per channel; ids are 16-bit and the DTLS client role conventionally uses even ids while the server role uses odd ids when SCTP auto-assigns, so pick explicit ids to avoid collisions. A negotiated channel reaches open as soon as the SCTP association is ready — there is no remote announcement, so guard your first send on readyState rather than assuming the peer has created its side. Negotiated channels pair naturally with a stable signaling layer; if you are still building that, the WebSocket Signaling Implementation guide covers the offer/answer plumbing that precedes channel setup.
Step 4 — Verification: buffering, backpressure, and message size
A correct reliability profile is worthless if the send buffer overflows. RTCDataChannel.send() is non-blocking: it enqueues into bufferedAmount and returns immediately. Push faster than SCTP can drain and bufferedAmount climbs without bound, raising latency and eventually consuming gigabytes of heap. The fix is bufferedAmountLowThreshold plus the onbufferedamountlow event, which together implement cooperative backpressure.
const file = pc.createDataChannel('file', { ordered: true });
const CHUNK = 16 * 1024; // 16 KiB chunks stay under SCTP fragmentation limits
const HIGH_WATER = 1 * 1024 * 1024; // pause sending above 1 MiB queued
file.bufferedAmountLowThreshold = 256 * 1024; // resume when buffer drains below 256 KiB
async function sendBlob(blob) {
const reader = blob.stream().getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
for (let offset = 0; offset < value.byteLength; offset += CHUNK) {
const slice = value.subarray(offset, offset + CHUNK);
// Apply backpressure: wait until the queue drains before enqueuing more
if (file.bufferedAmount > HIGH_WATER) {
await new Promise((resolve) => {
file.onbufferedamountlow = () => { file.onbufferedamountlow = null; resolve(); };
});
}
file.send(slice);
}
}
}
Verify the channel end to end before shipping: confirm readyState === 'open' on both peers, log chat.bufferedAmount during a sustained send to prove it stays bounded, and inspect chrome://webrtc-internals for the data-channel stats (messagesSent, bytesSent, messagesReceived) and the SCTP transport state: 'connected'. Keep individual messages within safe size limits: although browsers now negotiate an a=max-message-size SDP attribute and support EOR-based fragmentation for large messages, the interoperable ceiling for older endpoints is 16 KiB per message. Chunk anything larger at the application layer rather than relying on a single multi-megabyte send.
A few numbers anchor the verification. Poll getStats() at 1 s intervals — the same cadence used for media diagnostics — and watch the data-channel report’s bytesSent increase smoothly; a flat line while bufferedAmount climbs means SCTP is congestion-limited and you are over-producing. On a healthy local network, a 16 KiB-chunked file transfer with the backpressure loop above keeps bufferedAmount oscillating between 0 and your 1 MiB high-water mark, never exceeding it. If you see bufferedAmount spike to tens of megabytes, your threshold logic is not being awaited correctly. Cross-check that messagesSent on the sender equals messagesReceived on the receiver for a reliable channel; any gap on a channel you configured as reliable indicates the channel actually closed or was created with an unintended partial-reliability option.
Message size also interacts with fragmentation. SCTP fragments a large message into multiple chunks and only delivers it to the application once the final fragment (the end-of-record marker) arrives, so a 1 MiB message blocks delivery of everything behind it on an ordered channel until every fragment lands. This is a second reason to chunk at the application layer: small messages give the receiver progress, bound memory on both ends, and keep an ordered channel responsive. Treat 16 KiB as the safe unit of work and reassemble in your own code.
Edge Cases & Browser Quirks
- Default
binaryTypediffers. Chrome defaults to'blob', which forces an async read to inspect bytes; setbinaryType = 'arraybuffer'explicitly for synchronous access. Firefox has historically defaulted to'arraybuffer'. - Max message size negotiation. Chrome 77+ and Firefox advertise
a=max-message-sizein the SDP and support messages well above 16 KiB when both peers agree. Safari and older endpoints may cap at 64 KiB or 16 KiB; sending a 200 KiB message to such a peer raises an error or silently closes the channel. Always chunk to 16 KiB for maximum interoperability. maxRetransmitsandmaxPacketLifeTimetogether throw. Setting both in the samecreateDataChannelcall raises aTypeErrorin Chrome and Firefox. Choose one.- Stream id exhaustion. SCTP supports 65,535 streams, but creating and closing thousands of in-band channels without reuse can fragment id allocation; prefer a small set of long-lived negotiated channels for high-churn applications.
bufferedAmountLowThresholddefaults to 0. Without setting it,onbufferedamountlowonly fires when the buffer fully empties — too late for smooth backpressure. Set an explicit threshold.- Safari SCTP startup latency. Safari’s data channel
opencan lag the DTLS handshake by a few hundred milliseconds more than Chrome; never send on a timer assumption, always gate on theopenevent.
Common Implementation Mistakes
- Sending before
open. Callingsend()whilereadyStateis'connecting'throwsInvalidStateError. Queue messages or wait foronopen. - Ignoring backpressure. Looping
send()over a large file without checkingbufferedAmountbuilds an unbounded queue, spikes latency, and can crash the tab with an out-of-memory error. - Assuming ordered+reliable is free. A fully reliable ordered channel reintroduces TCP-style head-of-line blocking; one lost packet stalls every later message on that stream until retransmission succeeds. Use a separate unordered channel for latency-sensitive traffic.
- Mismatched negotiated ids. Different
idvalues on each peer for anegotiated: truechannel produce two half-open channels that never exchange data and never error loudly. - Treating a data channel as a guaranteed datagram path. Even
maxRetransmits: 0traffic still rides SCTP flow control; flooding it can still build congestion. Rate-limit at the application layer. - One giant message instead of chunks. Relying on a single multi-megabyte
sendbreaks against peers with a smallmax-message-sizeand prevents progress reporting. Chunk and reassemble.
FAQ
Why use a data channel instead of a WebSocket?
A data channel is peer-to-peer, so it avoids a server round trip and inherits WebRTC’s DTLS encryption and NAT traversal. It also offers per-channel unreliable/unordered delivery that WebSocket (built on TCP) cannot, which matters for sub-frame-latency state updates. Use WebSocket for client-to-server signaling and data channels for peer-to-peer payloads.
What is the practical maximum message size?
Send 16 KiB per message for universal interoperability. Modern Chrome and Firefox negotiate a=max-message-size and handle larger fragmented messages, but Safari and legacy endpoints can cap lower. Chunk large payloads at the application layer and reassemble on receipt.
How do I stop a fast sender from exhausting memory?
Set bufferedAmountLowThreshold, watch bufferedAmount, and pause sending until the onbufferedamountlow event fires. This implements cooperative backpressure so the SCTP send queue stays bounded regardless of how fast the application produces data.
Do data channels survive an ICE restart?
Yes. The SCTP association rides the same DTLS transport, so a successful ICE restart that re-establishes the path keeps channels open. Only a full connection failure tears them down. Coordinate restarts through your SDP Offer/Answer Lifecycle handling.
Related: return to the WebRTC Protocol Stack & Signaling Servers guide for the full transport picture, dig into the Reliable vs Unreliable Data Channels for Game State decision, set up the negotiation path with WebSocket Signaling Implementation, and review ICE Candidate Gathering & Filtering for the transport beneath SCTP.