Most people meet WebRTC through a video call, but the protocol carries far more than camera frames.
RTCDataChannel gives you a bidirectional pipe between two browsers (or a browser and a server)
that moves arbitrary bytes with no application server sitting in the data path. Once the connection is up,
packets flow directly between peers. That is what makes it useful for things video and audio tracks can
never do well: low-latency game state, collaborative cursors, chunked file transfer, screen-annotation
events, and telemetry streams.
This post is about the data channel specifically: the transport it rides on, the knobs you get when you create one, how to avoid clobbering yourself with backpressure, and where it beats a plain WebSocket (and where it absolutely does not). If you have read our WebRTC primer, the connection-setup half will feel familiar; everything after the handshake is new territory.
Why a separate transport instead of just reusing media RTP
Audio and video tracks travel as SRTP — RTP packets with encryption applied. RTP is designed around the assumption that a late packet is worthless: if a video frame arrives 400 ms behind, you would rather drop it and move on than block the stream waiting for it. That trade-off is wrong for application data, where a missing byte in the middle of a JSON message corrupts everything after it.
So data channels use a different stack. Underneath, the bytes go through SCTP (Stream Control Transmission Protocol), which is tunneled inside a DTLS session, which itself runs over the UDP path that ICE negotiated. The full sandwich is SCTP over DTLS over UDP, and the same ICE/STUN/TURN machinery that connects a video call is what connects a data channel. SCTP is the interesting layer: it was originally a telephony signaling transport, and it brings message framing, optional reliability, optional ordering, and multiplexing of many logical streams over one association. That feature set is exactly why WebRTC adopted it rather than reinventing TCP-in-the-browser.
| Layer | Media tracks | Data channels |
|---|---|---|
| Application framing | RTP payload | SCTP messages |
| Reliability model | Best-effort, drop-late | Configurable per channel |
| Encryption | SRTP (DTLS-SRTP keying) | DTLS |
| Connectivity | ICE / STUN / TURN | ICE / STUN / TURN (shared) |
Both stacks share one RTCPeerConnection, so a single negotiated connection can carry video,
audio, and several data channels at once. The encryption is mandatory and on by default; there is no
cleartext data-channel mode.
Creating a channel and the options that matter
The simplest call gives you a channel that behaves like TCP — reliable and ordered:
const pc = new RTCPeerConnection({ iceServers: [/* stun/turn here */] });
const chat = pc.createDataChannel("chat");
chat.onopen = () => chat.send("hello");
chat.onmessage = (e) => console.log("got", e.data);
The second argument is where the behavior is shaped. The fields you will actually reach for:
-
ordered(defaulttrue) — whether messages are delivered in send order. Set it tofalsewhen a later message supersedes an earlier one anyway, such as a player's latest position. -
maxRetransmits— cap on retransmission attempts before SCTP gives up on a message.0means "send once, never retransmit." -
maxPacketLifeTime— a time budget in milliseconds; SCTP stops retransmitting a message once it is older than this. -
negotiatedandid— how the two sides agree the channel exists.
One hard rule: maxRetransmits and maxPacketLifeTime are mutually
exclusive. Setting both throws a TypeError. Each one independently turns a reliable
channel into a partially reliable one; you pick the dimension you care about (attempts or time), not both.
Negotiated vs in-band channels
By default channels are negotiated in band: the side that calls createDataChannel
triggers an ondatachannel event on the remote peer, who picks up the channel from the event.
The SCTP stream id is assigned automatically. That is convenient but requires a renegotiation/announcement
round trip for each channel.
For channels you know about ahead of time on both ends, use out-of-band negotiation: pass
negotiated: true with an explicit matching id on both peers. No
ondatachannel event fires; each side just creates its own handle to the same stream. This is
cleaner for fixed control channels and avoids a setup race.
// Both peers run this with the SAME id:
const control = pc.createDataChannel("control", {
negotiated: true,
id: 0,
ordered: true,
});
The reliability spectrum
Combining ordered with the retransmit/lifetime knobs gives you a small menu of delivery
guarantees. Choosing correctly is the single biggest lever on a data channel's behavior under loss.
| Mode | Options | Guarantee | Good for |
|---|---|---|---|
| Reliable + ordered (TCP-like) | ordered: true, no caps |
Every byte, in order | File transfer, chat history, document sync |
| Reliable + unordered | ordered: false, no caps |
Every message, any order | Independent records, key/value updates |
| Partially reliable (by attempts) | maxRetransmits: 0–N |
Best-effort with bounded retries | Multiplayer position/input updates |
| Partially reliable (by time) | maxPacketLifeTime: 100 |
Drop if older than budget | Live telemetry, sensor feeds, cursors |
For a multiplayer game, an unordered channel with maxRetransmits: 0 is often ideal: if a
position packet is lost, the next one (200 ms later) makes it irrelevant, so retransmitting wastes
bandwidth and adds latency. For a chat message, you want the opposite — the default reliable, ordered
channel — because a dropped sentence is a bug, not a stale frame.
Message size, fragmentation, and why big sends fail
SCTP can fragment and reassemble large messages, but browsers historically capped how much they would
accept in one send(). The number that gets exchanged is maxMessageSize, advertised
in the SDP and exposed on pc.sctp.maxMessageSize. Modern Chromium and Firefox advertise large
values (often 256 KB or effectively unbounded), but Safari and older builds were far more conservative, and
some stacks historically choked above 64 KB.
The practical, portable rule as of 2026: chunk anything large into roughly 16 KB pieces.
16 KB sits safely under every interoperable limit, keeps individual messages from monopolizing the SCTP
send window, and gives you natural points to check backpressure. A single 50 MB send() may
throw, silently fail, or stall the connection depending on the peer; a stream of 16 KB chunks is boring and
reliable. Always read maxMessageSize at runtime rather than hardcoding an assumption:
const limit = pc.sctp?.maxMessageSize ?? 65536;
const CHUNK = Math.min(16 * 1024, limit);
Backpressure: the part everyone gets wrong first
send() is fire-and-forget from the caller's perspective — it returns immediately and
queues the data. If you loop over a 200 MB file calling send() as fast as JavaScript runs, you
do not transfer it faster; you balloon the send buffer, spike memory, and on some browsers hit an abrupt
close once the buffer overflows. The queue depth is bufferedAmount, and the mechanism for
flow control is bufferedAmountLowThreshold plus the bufferedamountlow event.
The pattern: set a threshold, fill the buffer up to a ceiling, then pause and wait for the buffer to drain below the threshold before sending more. Here is a complete chunked file sender that respects backpressure:
async function sendFile(channel, file) {
channel.binaryType = "arraybuffer";
const CHUNK = Math.min(16 * 1024, channel.maxMessageSize || 65536);
const HIGH_WATER = 1 * 1024 * 1024; // pause when 1 MB is queued
channel.bufferedAmountLowThreshold = 256 * 1024;
// Tell the receiver what is coming.
channel.send(JSON.stringify({ type: "meta", name: file.name, size: file.size }));
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + CHUNK);
const buf = await slice.arrayBuffer();
channel.send(buf);
offset += buf.byteLength;
if (channel.bufferedAmount > HIGH_WATER) {
await new Promise((resolve) => {
channel.addEventListener("bufferedamountlow", resolve, { once: true });
});
}
}
channel.send(JSON.stringify({ type: "done" }));
}
The receiver reassembles by appending each ArrayBuffer until the byte count matches the
advertised size, then builds a Blob. Without the HIGH_WATER check and the
bufferedamountlow wait, a large file will either run the tab out of memory or trip the SCTP
buffer limit and tear the channel down.
binaryType and what you can actually send
send() accepts a string, an ArrayBuffer, a typed-array view
(Uint8Array and friends), or a Blob. Strings arrive as strings. For binary, the
receiving side's event.data type is controlled by channel.binaryType, which is
either "arraybuffer" (default in most engines) or "blob".
-
Use
"arraybuffer"when you process bytes immediately — parsing a binary protocol, feeding a decoder, copying into a buffer. It is synchronous to read. -
Use
"blob"when you will hand the data straight to something that takes a Blob (an<a download>,URL.createObjectURL) and want the engine to keep large payloads off the JS heap. - Prefer strings for small structured control messages (JSON). The overhead is negligible and debugging is trivial.
A common pattern is one reliable JSON control channel plus one binary channel tuned for the payload, all on the same peer connection.
Data channel vs WebSocket: pick deliberately
A WebSocket is a client-to-server pipe; a data channel is (usually) peer-to-peer. That difference drives everything else.
| Property | RTCDataChannel | WebSocket |
|---|---|---|
| Path | Peer to peer (or via TURN relay) | Client to server, always |
| Latency | Lower — no server hop in the data path | Adds a server round trip |
| Reliability | Configurable (can drop late data) | Reliable, ordered only (TCP) |
| Setup cost | ICE handshake + signaling exchange | One HTTP upgrade |
| NAT traversal | Needs STUN, often TURN | None (server has a public address) |
Reach for a data channel when you need direct peer connectivity, when you want unreliable/unordered delivery for latency-sensitive data, or when keeping payloads off your servers matters for cost or privacy. Reach for a WebSocket when the data is going to a server anyway, when you need a connection up in one round trip, or when you simply do not want to operate STUN/TURN infrastructure. Many production apps use both: WebSocket for signaling and authoritative server state, data channels for the peer-to-peer fast path.
Connectivity still applies — even with no media
A data-channel-only RTCPeerConnection is not exempt from NAT traversal. There is no media, but
there is still an ICE negotiation, and the same things that break a video call — symmetric NATs,
corporate firewalls that block UDP, restrictive mobile carriers — will break a data channel. When
direct and STUN-assisted paths fail, the connection falls back to a TURN relay, at which
point your "serverless" data path is no longer serverless.
If a data channel never reaches the open state for some users, the cause is almost always ICE,
not the channel API. Confirm your STUN/TURN servers actually return candidates before you spend a day
debugging send(). You can validate that directly with our
free ICE server tester, and our
debugging guide walks through reading the ICE
candidate flow when things stall.
Production notes
Throughput on a healthy direct path commonly lands in the tens to low hundreds of Mbps, but it is gated by
the SCTP congestion window, your chunk size, and how diligently you respect backpressure — a naive
sender is frequently 5–10x slower than a well-tuned one. The DTLS layer adds CPU cost for encryption
on every packet; for bulk transfer this is usually fine on modern hardware, but on low-end mobile it is a
real factor in battery and sustained rate. Browser interop for data channels is mature in 2026 —
Chromium, Firefox, and Safari all ship solid SCTP implementations — with the main remaining gotcha
being maxMessageSize differences, which the 16 KB chunking rule sidesteps entirely. Treat data
security like the rest of WebRTC: the transport is encrypted, but you still authenticate peers and validate
everything you receive, as covered in our
security best practices.
Check Your Own Servers
A data channel that never opens is almost always an ICE problem, not a bug in your send() code.
Confirm your STUN and TURN servers are returning candidates and reachable from real networks before you
ship that file-transfer feature.