Data Channels

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page was written on 2026-10-24 (checked on 2026-10-24) against the W3C WebRTC 1.0 Recommendation (2025-03-13 edition), RFC 8831 (data channels), RFC 8832 (the data channel establishment protocol) and RFC 8841 (SDP for SCTP), and the server-side libraries node-datachannel 0.33.4, aiortc 1.15.0 and Pion 4.2.25. The examples were run in Chrome with two pages in the same browser talking over the loopback interface; the server-peer examples were run with Node.js 24, Python 3.12 and Go 1.25.

The WebRTC Data Channels (AI conversational channels) page already shows a single data channel carrying a chat assistant, an aiortc server and message chunking in that context; it is not repeated here. This page is the general reference: what a data channel is on the wire, every creation option and what it means for the three scenarios of this section, how to apply back-pressure, how to move large payloads, how to put a data channel on the server side, and what can go wrong.

Which scenario uses them:

  • Huddle (videoconference) uses a data channel for in-call chat, hand raising, "who is speaking" hints, a file drop and the control messages of a mesh call. In SFU mode the media goes to the server but the application usually keeps using the signalling WebSocket for those events; a data channel to the SFU is optional.

  • Pixel Arena (game) is the case where data channels shine: player inputs and world snapshots over an unordered, unretransmitted channel, with a reliable channel beside it for joins, scores and chat. See Multiplayer Game Architecture and Latency Compensation.

  • Chatterbox (chat) almost never needs one: a WebSocket already gives reliable, ordered delivery through every proxy (see Comparison & Decision Guide).

What a data channel is

A data channel is a bidirectional message pipe attached to an RTCPeerConnection (Peer Connections and SDP). It is independent of audio and video: a connection with no media at all can carry only data channels, and the first channel created is what makes the offer contain a data section.

The stack, from the bottom up:

  • ICE selects a UDP (or TCP-relayed) path through NATs, as described in ICE, STUN and TURN.

  • DTLS encrypts and authenticates that path, using the certificate fingerprints exchanged in the SDP. Data channels are therefore always encrypted; there is no unencrypted mode.

  • SCTP (RFC 8831) runs inside DTLS. It is a message-oriented protocol with multiple independent streams, and one data channel is mapped onto one pair of SCTP streams. Partial reliability comes from the PR-SCTP extension (RFC 3758) and, where available, stream interleaving of large messages from RFC 8260.

  • The data channel establishment protocol, DCEP (RFC 8832), sends a small DATA_CHANNEL_OPEN message on the first stream use carrying the label, the protocol string and the reliability parameters, and the peer answers with an acknowledgement. This is called an in-band (non-negotiated) channel.

A single peer connection multiplexes up to 65,534 channels over one SCTP association, so adding a channel costs a few bytes of state and one DCEP handshake, not another ICE negotiation. The contrast with a WebSocket is the point of the comparison table:

WebSocket RTCDataChannel

Transport

TCP (TLS) through HTTP, works through every proxy

SCTP over DTLS over UDP, needs ICE and often TURN

Delivery

always reliable and ordered

per channel: reliable or partial, ordered or not

Head-of-line blocking

yes, for the whole connection

per channel only, and not at all when unordered

Topology

client to server

peer to peer, or peer to server with a WebRTC stack on the server

Setup

one HTTP upgrade

signalling, offer and answer, ICE, DTLS, SCTP

Message types

text and binary frames

strings and binary (ArrayBuffer or Blob)

Creating a channel and its options

const chat  = pc.createDataChannel("chat");                                           // reliable, ordered
const arena = pc.createDataChannel("arena", { ordered: false, maxRetransmits: 0 });   // fire and forget
const files = pc.createDataChannel("file");

createDataChannel(label, options) returns the channel immediately, in the connecting state. The remote side learns of it through the datachannel event of its peer connection. The options are:

Option Default Meaning

ordered

true

Deliver messages in the order sent. With false, a message that arrives complete is delivered even if an earlier one is still being retransmitted.

maxRetransmits

unset

Retransmission attempts per message. 0 means send once and never retry. Setting it makes the channel partially reliable.

maxPacketLifeTime

unset

Milliseconds during which retransmission is attempted. Mutually exclusive with maxRetransmits: supplying both makes createDataChannel throw a TypeError.

negotiated

false

When true, the application creates the channel on both sides with the same id and no DCEP message is exchanged.

id

assigned

The SCTP stream number (0 to 65534). Required for negotiated channels; for in-band channels the browser picks it once the DTLS role is known, and it reads as null until then.

protocol

empty string

A free-form sub-protocol name, carried to the peer, for example huddle.control.v1. It is how both sides agree on a framing for the channel.

The read-only attributes ordered, maxRetransmits, maxPacketLifeTime, negotiated, id, label and protocol mirror what was requested, so the creating side can assert them. On the answering side a browser reads them from the parameters that arrived in the DCEP message.

Choosing reliability per message class

The right setting depends on what a lost or late message costs:

  • Chat and control messages (Huddle’s hand raise, Pixel Arena’s "player joined") must arrive and must arrive in order: use the default channel.

  • High-rate state (a Pixel Arena input sent every 16 ms, a snapshot at 20 Hz) is superseded by the next message. Retransmitting a lost snapshot only delivers stale data after the fresh one is already late. Use ordered: false, maxRetransmits: 0, and put a sequence number or tick in every message so the receiver can discard anything older than what it has applied.

  • Time-bounded data (a voice-activity hint useful for 200 ms) fits maxPacketLifeTime: 200 better than a retry count, because the budget is expressed in the unit the application cares about.

  • Bulk data (a file) must be reliable. Ordered delivery is the simple choice; unordered with explicit offsets is possible but rarely worth the complexity.

Use separate channels for separate classes. Ordering is per channel, so a slow reliable file transfer on one channel does not delay snapshots on another, but all channels share the same congestion window of the one SCTP association: a large transfer can still raise the latency of the others, which is why games throttle bulk transfers.

Negotiated channels

An in-band channel exists on the remote side only after the DCEP open message arrives, which makes "who creates it" an ordering problem: both sides must be ready to receive the datachannel event, and a perfect-negotiation page where both peers may call createDataChannel can end up with two channels of the same label. A negotiated channel removes that problem: both peers run the same line of code.

const control = pc.createDataChannel("control", { negotiated: true, id: 5, protocol: "huddle.control.v1" });
control.onopen = () => control.send(JSON.stringify({ type: "ready" }));
control.onmessage = ({ data }) => console.log("control:", JSON.parse(data));

The price is that the application owns the id space: two negotiated channels with the same id, or a negotiated id that collides with an in-band one, fail with an OperationError. Reserve a small range of ids for negotiated channels (for example 0 to 15) and let the browser allocate the rest. In-band ids are allocated so that the two ends cannot clash: by RFC 8832 the DTLS client uses even stream ids and the DTLS server odd ones. Negotiated channels are also the way to open a channel before the connection is established, so that data can be queued the moment ICE completes.

Channel lifecycle and events

An RTCDataChannel moves through four values of readyState:

State Meaning

connecting

Created, waiting for the SCTP association (and, for in-band channels, the DCEP acknowledgement).

open

send() is allowed. The open event fires.

closing

close() was called or the peer asked to close. Queued data is still flushed.

closed

The close event fires. After a transport failure error fires first.

The events are open, message, bufferedamountlow, error, closing and close. Two details matter in practice. send() on a channel that is not open throws an InvalidStateError, so a producer must either wait for open or queue messages itself. And when the peer connection itself ends (ICE failed, pc.close()), every channel closes without a per-channel handshake, so connection recovery belongs on the peer connection as described in Signaling and Perfect Negotiation and Connection Lifecycle, Heartbeats and Reconnection.

binaryType decides how binary messages arrive. The specification default is "blob", which forces an asynchronous read; set channel.binaryType = "arraybuffer" on every channel that carries binary data so frames can be parsed synchronously.

A complete loopback example: Huddle chat, Pixel Arena and a file

The example below is the smallest runnable program that exercises all of it: a signalling relay, one page that plays the caller and the callee depending on the URL, three in-band channels (a reliable chat channel, an unreliable Pixel Arena channel and a flow-controlled file channel) and a negotiated control channel. Open the page twice in the same browser (?role=callee first, then ?role=caller) and both sides connect over the loopback interface.

{
  "name": "huddle-data-channels",
  "type": "module",
  "dependencies": {
    "ws": "8.22.0"
  }
}
// signal.js -- relays every message to the other sockets of the same room
import { WebSocketServer } from "ws";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";

const PORT = Number(process.env.PORT ?? 8080);
const rooms = new Map();                                   // room name -> Set of sockets

const http = createServer(async (req, res) => {
  if (new URL(req.url, "http://localhost").pathname !== "/") { res.writeHead(404).end(); return; }
  res.writeHead(200, { "content-type": "text/html" }).end(await readFile(new URL("huddle.html", import.meta.url)));
});

const wss = new WebSocketServer({ server: http, path: "/signal" });
wss.on("connection", (ws, req) => {
  const room = new URL(req.url, "http://localhost").searchParams.get("room") ?? "lobby";
  const peers = rooms.get(room) ?? new Set();
  rooms.set(room, peers);
  peers.add(ws);
  ws.on("message", (data, isBinary) => {
    for (const other of peers) if (other !== ws && other.readyState === 1) other.send(data, { binary: isBinary });
  });
  ws.on("close", () => { peers.delete(ws); if (peers.size === 0) rooms.delete(room); });
});

http.listen(PORT, "127.0.0.1", () => console.log(`signalling on http://127.0.0.1:${PORT}`));

This relay has no authentication, no origin check and no limits; it exists to make the data channel code testable. A production signalling server follows Authentication and Authorization and Security.

<!doctype html>
<meta charset="utf-8">
<title>Huddle data channels</title>
<script type="module">
const params = new URLSearchParams(location.search);
const role = params.get("role");                       // "caller" or "callee"
const room = params.get("room") ?? "huddle-1";
const note = (name, value) => console.log(JSON.stringify({ name, value }));

const pc = new RTCPeerConnection({ iceServers: [] });  // loopback: host candidates are enough
const signal = new WebSocket(`ws://${location.host}/signal?room=${room}`);
const send = (msg) => signal.send(JSON.stringify(msg));

pc.onicecandidate = ({ candidate }) => { if (candidate) send({ candidate: candidate.toJSON() }); };
signal.onmessage = async ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.description) {
    await pc.setRemoteDescription(msg.description);
    if (msg.description.type === "offer") {
      await pc.setLocalDescription();
      send({ description: pc.localDescription });
    }
  }
  if (msg.candidate) await pc.addIceCandidate(msg.candidate);
};
signal.onopen = () => { if (role === "caller") call(); };

// Both sides create the negotiated channel with the same id; no in-band open message is exchanged.
const control = pc.createDataChannel("control", { negotiated: true, id: 5, protocol: "huddle.control.v1" });
control.onopen = () => { if (role === "callee") control.send(JSON.stringify({ type: "ready" })); };
control.onmessage = ({ data }) => {
  if (JSON.parse(data).type === "ready") note("negotiated-control-open", { id: control.id, protocol: control.protocol });
};

pc.ondatachannel = ({ channel }) => wire(channel);

function wire(dc) {
  dc.binaryType = "arraybuffer";
  if (dc.label === "chat") {
    dc.onmessage = ({ data }) => {
      const msg = JSON.parse(data);
      if (role === "callee") dc.send(JSON.stringify({ type: "chat.ack", seq: msg.seq }));
      else note("chat-ack", msg.seq);
    };
  }
  if (dc.label === "arena") {
    let received = 0;
    dc.onmessage = ({ data }) => {
      if (role === "callee") { received++; if (received === 50) dc.send(JSON.stringify({ received })); }
      else note("arena-reply", JSON.parse(data));
    };
  }
  if (dc.label === "file") fileReceiver(dc);
}

async function call() {
  const chat = pc.createDataChannel("chat");                                      // reliable and ordered
  const arena = pc.createDataChannel("arena", { ordered: false, maxRetransmits: 0 });
  const file = pc.createDataChannel("file");
  for (const dc of [chat, arena, file]) wire(dc);
  note("settings", { arena: { ordered: arena.ordered, maxRetransmits: arena.maxRetransmits, maxPacketLifeTime: arena.maxPacketLifeTime } });
  await pc.setLocalDescription();
  send({ description: pc.localDescription });

  await Promise.all([opened(chat), opened(arena), opened(file)]);
  note("maxMessageSize", pc.sctp.maxMessageSize);
  for (let seq = 1; seq <= 3; seq++) chat.send(JSON.stringify({ type: "chat.message", seq, text: `hello ${seq}` }));
  for (let tick = 1; tick <= 50; tick++) arena.send(JSON.stringify({ tick, x: tick, y: 2 * tick }));
  await sendFile(file, new Uint8Array(1_500_000).map((_, i) => i % 251), "map.bin");
}

const opened = (dc) => dc.readyState === "open"
  ? Promise.resolve()
  : new Promise((resolve) => dc.addEventListener("open", resolve, { once: true }));
</script>

The sendFile and fileReceiver functions, which belong in the same module script, are shown in the flow-control section below. In the run recorded for this page the caller read ordered: false, maxRetransmits: 0 and maxPacketLifeTime: null back from the arena channel, the negotiated channel opened with id 5 and the protocol string on both sides, all three chat messages were acknowledged in order, and the callee received all 50 arena messages. Receiving all 50 is expected on loopback: unreliable delivery allows loss, it does not cause it, so a test that expects drops needs a network emulator (for example Chrome DevTools throttling or tc netem).

Flow control with bufferedAmount

send() never blocks. It appends the message to an internal queue and returns, and the channel’s bufferedAmount attribute reports how many bytes are still queued. A producer that loops over a large file without looking at bufferedAmount fills memory and, past an implementation-defined limit (of the order of 16 MiB in Chromium), makes the browser close the channel. The mechanism for pacing a sender is:

  • bufferedAmount is the number of bytes handed to send() and not yet passed to the network.

  • bufferedAmountLowThreshold (default 0) is a level; when bufferedAmount falls from above it to at or below it, the bufferedamountlow event fires.

The producer therefore pauses above a high-water mark and resumes on bufferedamountlow. The threshold should be well above zero: with the default 0 the queue is completely drained before the event fires and the link idles while the producer wakes up and refills it.

async function sendFile(dc, bytes, name) {
  const CHUNK = 16 * 1024;                              // 16 KiB is safe across browsers
  dc.bufferedAmountLowThreshold = 256 * 1024;           // resume when the queue is below 256 KiB
  dc.send(JSON.stringify({ type: "file.start", name, size: bytes.byteLength }));
  let peak = 0, waits = 0;
  for (let offset = 0; offset < bytes.byteLength; offset += CHUNK) {
    if (dc.bufferedAmount > 1024 * 1024) {              // high-water mark: pause above 1 MiB
      waits++;
      await new Promise((resolve) => dc.addEventListener("bufferedamountlow", resolve, { once: true }));
    }
    dc.send(bytes.subarray(offset, offset + CHUNK));
    peak = Math.max(peak, dc.bufferedAmount);
  }
  dc.send(JSON.stringify({ type: "file.end" }));
  note("file-sent", { size: bytes.byteLength, peakBuffered: peak, waits });
}

function fileReceiver(dc) {
  let expected = 0, got = 0, sum = 0;
  dc.onmessage = ({ data }) => {
    if (typeof data === "string") {                     // control frames are JSON text, chunks are binary
      const msg = JSON.parse(data);
      if (msg.type === "file.start") expected = msg.size;
      if (msg.type === "file.end") dc.send(JSON.stringify({ type: "file.done", got, sum }));
      if (msg.type === "file.done") note("file-done", msg);
      return;
    }
    got += data.byteLength;
    for (const b of new Uint8Array(data)) sum = (sum + b) >>> 0;
  };
}

In the recorded run the 1,500,000-byte file was sent in 92 chunks, the sender paused once at the 1 MiB mark, and the receiver reported exactly 1,500,000 bytes and the same checksum the sender computed (187,497,276). Because the channel is reliable and ordered, the receiver needs no offsets or reassembly logic: a text frame starts the file, binary frames append, and a text frame ends it. Two details of the code deserve attention. The text control frames and the binary chunks share one ordered channel, which is what makes that simple framing safe; on an unordered channel every chunk would need an index. And the receiver should verify the size and a digest, not trust the sender’s frame, before it writes anything to disk.

bufferedAmount counts only what the sender has queued. It says nothing about how fast the receiver consumes messages, so an application-level credit scheme (the receiver sends "ack up to chunk N" and the sender keeps a window) is the way to protect a slow consumer; browsers do not expose receive-side back-pressure for data channels. The WebSocket counterpart of this technique is covered in The Browser WebSocket API.

Message size limits

A message cannot be larger than the remote description’s a=max-message-size attribute, exposed as pc.sctp.maxMessageSize once the transport exists. RFC 8841 sets the default for a peer that omits the attribute at 65,536 bytes, and a peer may advertise much more. Chrome advertised 262,144 bytes in the recorded run. send() throws a TypeError for a message over the limit. The portable rule used above is to send at most 16 KiB per message, which every major implementation accepts, and to split anything larger in the application. Large messages also occupy the association for their whole length, which delays other channels' messages unless the stack interleaves them (RFC 8260), so smaller chunks are kinder to the game channel running beside a file transfer.

Peer-to-server data channels

A browser can open a data channel to a process on a server just as it does to another browser, provided the server runs a WebRTC stack. The motivation is Pixel Arena: an authoritative server that receives inputs and sends snapshots over an unreliable channel avoids TCP head-of-line blocking, which is the main reason a WebSocket game stutters on a lossy network (see Multiplayer Game Architecture). The cost is that the server needs a UDP-reachable address, ICE, and a signalling channel; the browser sees it as just another peer. The libraries differ in language and in how closely they follow the browser API:

  • node-datachannel (0.33.4) binds libdatachannel to Node.js and ships a node-datachannel/polyfill module that exposes the browser RTCPeerConnection API, so the same code shape works on both sides. It carries data channels only (it is not an SFU).

  • aiortc (1.15.0) is the asyncio implementation of WebRTC for Python; its createDataChannel takes the same options as the browser, and bufferedAmount and bufferedAmountLowThreshold exist on its channels.

  • Pion (4.2.25) is the Go implementation and the base of several media servers; its DataChannelInit takes pointer fields so that "unset" is distinguishable from zero.

The Pion and aiortc page covers the libraries as media stacks; this section concerns only their data channel API.

Node.js with node-datachannel

The server peer answers the browser’s offer and replies to each input with a snapshot that acknowledges the input sequence. It reuses the relay of the previous section, in a different room.

// arena-server.js -- Pixel Arena server peer
import { RTCPeerConnection } from "node-datachannel/polyfill";

const signal = new WebSocket(`ws://127.0.0.1:${process.env.PORT ?? 8080}/signal?room=${process.env.ROOM ?? "arena-1"}`);
const pc = new RTCPeerConnection({ iceServers: [] });
const send = (msg) => signal.send(JSON.stringify(msg));

pc.onicecandidate = ({ candidate }) => candidate && send({ candidate: candidate.toJSON() });
pc.ondatachannel = ({ channel }) => {
  if (channel.label !== "arena") return;
  let serverTick = 0;
  channel.onmessage = ({ data }) => {
    const input = JSON.parse(data);                    // {"seq":12,"dx":1,"dy":0}
    serverTick++;
    channel.send(JSON.stringify({ type: "snapshot", tick: serverTick, ackSeq: input.seq, you: { x: input.seq, y: 0 } }));
  };
};

signal.onmessage = async ({ data }) => {
  const msg = JSON.parse(data);
  if (msg.description) {
    await pc.setRemoteDescription(msg.description);
    await pc.setLocalDescription();
    send({ description: pc.localDescription });
  }
  if (msg.candidate) await pc.addIceCandidate(msg.candidate);
};
process.on("SIGINT", () => { pc.close(); process.exit(0); });

The browser half creates the unreliable channel, sends twenty inputs, and applies only snapshots newer than the last it has seen, which is the receive rule every unordered channel needs:

const arena = pc.createDataChannel("arena", { ordered: false, maxRetransmits: 0 });
let last = 0;
arena.onopen = () => { for (let seq = 1; seq <= 20; seq++) arena.send(JSON.stringify({ seq, dx: 1, dy: 0 })); };
arena.onmessage = ({ data }) => {
  const snap = JSON.parse(data);
  if (snap.tick > last) last = snap.tick;              // drop out-of-order snapshots instead of rewinding
  // apply snap.you / reconcile against snap.ackSeq only when snap.tick advanced
};

In the recorded run, Chrome (offering) and this Node.js server (answering) finished with lastTick: 20 and the player at x: 20, so all twenty inputs were acknowledged. One observed difference is worth knowing: in node-datachannel 0.33.4 the channel object on the answering side reported ordered: true and maxRetransmits: null for a channel the browser had created with ordered: false, maxRetransmits: 0. Read reliability on the side that created the channel, and do not rely on the answerer’s attributes to enforce policy: validate in the application (for example, accept only the labels and protocols you expect).

Python with aiortc

"""Pixel Arena server peer with aiortc: answers an offer and replies to inputs with snapshots."""
import asyncio
import json

from aiortc import RTCPeerConnection, RTCSessionDescription


async def answer_offer(offer_sdp: str) -> tuple[RTCPeerConnection, str]:
    pc = RTCPeerConnection()

    @pc.on("datachannel")
    def on_datachannel(channel):
        if channel.label != "arena":
            return
        tick = 0

        @channel.on("message")
        def on_message(message):
            nonlocal tick
            inp = json.loads(message)
            tick += 1
            channel.send(json.dumps({"type": "snapshot", "tick": tick, "ackSeq": inp["seq"]}))

    await pc.setRemoteDescription(RTCSessionDescription(sdp=offer_sdp, type="offer"))
    await pc.setLocalDescription(await pc.createAnswer())   # aiortc gathers candidates here
    return pc, pc.localDescription.sdp

requirements.txt pins aiortc==1.15.0. The function was exercised in a Python 3.12 container with a second aiortc peer in the same process acting as the browser: the client created arena with ordered=False, maxRetransmits=0, read False 0 None back on open, sent ten inputs and received ten snapshots. aiortc does not trickle candidates, so the answer SDP already contains them and the signalling layer needs to carry only the offer and the answer; the AI conversational channels page shows that shape wired to a FastAPI endpoint.

Go with Pion

// newArenaPeer returns a peer connection that answers a browser offer; signalling is left to the caller.
func newArenaPeer() (*webrtc.PeerConnection, error) {
	pc, err := webrtc.NewPeerConnection(webrtc.Configuration{})
	if err != nil {
		return nil, err
	}
	pc.OnDataChannel(func(dc *webrtc.DataChannel) {
		if dc.Label() != "arena" {
			return
		}
		tick := 0
		dc.OnMessage(func(msg webrtc.DataChannelMessage) {
			var in struct{ Seq int `json:"seq"` }
			if json.Unmarshal(msg.Data, &in) != nil {
				return
			}
			tick++
			reply, _ := json.Marshal(map[string]any{"type": "snapshot", "tick": tick, "ackSeq": in.Seq})
			_ = dc.Send(reply)
		})
	})
	return pc, nil
}

// the creator side: unordered, no retransmissions (fields are pointers so "unset" differs from zero)
func newAuthoritativeChannel(pc *webrtc.PeerConnection) (*webrtc.DataChannel, error) {
	ordered, retransmits := false, uint16(0)
	return pc.CreateDataChannel("arena", &webrtc.DataChannelInit{Ordered: &ordered, MaxRetransmits: &retransmits})
}

The module (github.com/pion/webrtc/v4 at v4.2.25) was compiled and a program creating the channel printed false 0 from dc.Ordered() and *dc.MaxRetransmits(). The offer and answer exchange is done by the caller exactly as in the Node.js example; Pion’s own data-channels example in its repository shows a complete pairing with a browser.

Common errors and pitfalls

Symptom Cause Fix

send() throws InvalidStateError

The channel is not open yet or has closed.

Wait for open, or check readyState; queue in the application while reconnecting.

send() throws TypeError on a big message

The message is larger than maxMessageSize, or both maxRetransmits and maxPacketLifeTime were set at creation.

Chunk to at most 16 KiB; set only one reliability option.

Channel closes during a file transfer

The send queue grew without bound because bufferedAmount was ignored.

Pause above a high-water mark and resume on bufferedamountlow.

ondatachannel never fires

The channel was created before the offer was made on the other side, or was negotiated: true on one side only.

Create in-band channels on the offerer, before createOffer; for negotiated ones, use the same id on both sides.

Two channels with the same label

Both peers created the channel in a glare situation.

Create the channel on one designated role, or use a negotiated channel.

Snapshots appear to go backwards

An unordered channel delivered an older message after a newer one.

Put a tick or sequence in each message and discard anything not newer.

Blob instead of ArrayBuffer

binaryType was left at its default.

Set dc.binaryType = "arraybuffer" before the first message.

Channel works on Wi-Fi but not on a corporate network

UDP is blocked and no TURN relay is configured.

Offer TURN over TLS on 443, see ICE, STUN and TURN.

Order differs between two channels

Ordering applies within a channel only.

Do not assume messages across different channels are ordered; carry a shared sequence if it matters.

Security

  • Transport. DTLS provides confidentiality and integrity, and the peer certificate fingerprint in the SDP binds the channel to the signalling exchange. Anyone who can tamper with signalling can still insert themselves, so the signalling channel must be authenticated and use TLS. The caveats about server-side visibility are in End-to-End Encryption (data channels between two browsers are end to end; a server peer terminates them by design).

  • Authentication. A data channel has no concept of a user. Authorise at signalling, tie each peer connection to the authenticated identity on the server, and never trust a userId field sent inside a channel message.

  • Input validation. Treat every channel message as untrusted input: parse inside a try block, check types and ranges, cap message size and rate per connection, and cap the number of channels a peer may open (a peer can create tens of thousands of channels per connection).

  • Resource limits. On a server peer, limit concurrent peer connections per user and per address, and close idle ones. The SCTP and DTLS code in a native stack is parsing untrusted network input, so pin versions and track the security releases of libdatachannel, aiortc or Pion.

  • Address exposure. Establishing a peer connection reveals the peer’s candidate addresses through ICE. Games and private rooms that must hide a player’s address from other players should relay everything through the server or use relay-only candidates (iceTransportPolicy: "relay").

  • Browser permissions. Data channels do not require a permission prompt, so a page can open peer connections silently, and a page-level policy on fetch and WebSocket destinations should not be assumed to cover them; see Security.

Version and specification history

  • Data channels were introduced in browsers around 2013 with SCTP over DTLS replacing an earlier RTP-based approach, and are specified by RFC 8831 (the transport, January 2021), RFC 8832 (DCEP) and RFC 8841 (SDP signalling of the SCTP association, including max-message-size).

  • The W3C WebRTC 1.0 Recommendation (2025-03-13 edition) defines the RTCDataChannel interface, RTCSctpTransport and the maxMessageSize attribute used above. The older maxRetransmitTime option was replaced by maxPacketLifeTime.

  • WebTransport (datagrams and streams to a server over HTTP/3) is an emerging alternative for games and live data that does not need ICE; see WebTransport and Media over QUIC.