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.

RTCDataChannel transport stack Four stacked layers: RTCDataChannel API on top, then SCTP for per-stream reliability and ordering, then DTLS for encryption, then ICE over UDP for NAT traversal. RTCDataChannel app API: ordered / unordered, reliable / partial SCTP streams, retransmit, fragmentation, flow control DTLS encryption, integrity, key exchange ICE / UDP candidate pairs, NAT traversal, datagrams
Each data channel is an SCTP stream inside one DTLS-encrypted association over ICE/UDP.

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

Common Implementation Mistakes

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.