RTCDataChannel

What a data channel is

RTCDataChannel is a bidirectional byte pipe between two browsers. It carries arbitrary application data (strings or binary) directly from one peer to the other. No application server sits in the data path.

The transport underneath is SCTP running over DTLS, itself running over UDP. SCTP supplies framing, congestion control, and configurable reliability. DTLS supplies encryption. Every byte you send is encrypted on the wire; there is no opt-out and no plaintext mode.

The model differs from a WebSocket in two ways that matter. A WebSocket connects a client to a server; a data channel connects a peer to a peer. A WebSocket runs on TCP, which forces in-order reliable delivery; a data channel lets you pick the reliability per channel.

You create a channel from an RTCPeerConnection that has already negotiated. You then send and receive through a small set of events. That is the whole surface.

// pc is a connected RTCPeerConnection
const channel = pc.createDataChannel('chat');

channel.onopen = () => channel.send('hello');
channel.onmessage = (e) => console.log('received', e.data);
PEER A PEER B "hello" ArrayBufferbytes channel.send()SCTP framing DTLSencrypt + auth onmessage(e) channelSCTP reassembly DTLSdecrypt + verify UDP socket one shared port

This guide assumes the connection already exists. Establishing it is a separate problem covered under connection protocols, which explains the ICE, DTLS, and SCTP layers in order. Here the focus is the channel itself: how to open one, what knobs it exposes, how to send each kind of payload, and how to structure the messages once they arrive.

Where the data channel sits

A data channel is not a separate connection. It is a stream multiplexed inside the one RTCPeerConnection, alongside any media tracks. SCTP carries the channels; SRTP carries the media; both ride the same DTLS session and the same UDP port.

PEER A Reliable Channelordered chat Unreliable Channelgame state Video Track SCTPmultiplexer SRTPmedia crypto DTLSsecurity UDP Socket Peer B one public port

One consequence: opening a second or third data channel costs no extra connection. They share the same encrypted association. SCTP gives each one a stream identifier and keeps their bytes apart. You pay for the handshake once.

A second consequence: each channel carries its own reliability setting. One connection can hold a reliable ordered channel for chat and an unreliable channel for position updates at the same time. The settings live on the channel, not the connection.

The transport layers underneath

A data channel is the top of a four-layer stack. Each layer has one job, and each one is set up before the channel can open. Knowing the order explains why a channel cannot send until the connection state reaches connected, and why a channel inherits encryption you never configure.

Layer Role Set up by
UDP A single host:port datagram socket ICE candidate selection
DTLS Encrypts and authenticates every datagram DTLS handshake over the chosen path
SCTP Framing, ordering, reliability, multiplexing SCTP association over DTLS
RTCDataChannel The application-facing send/receive API createDataChannel

ICE picks one working path through the network address translators on both ends and binds a UDP socket to it. That work (candidate gathering, connectivity checks, NAT traversal with public STUN) is the subject of connection protocols. This repository uses public STUN servers only; the data path is always peer-to-peer over that socket, never relayed.

DTLS runs a handshake over the chosen path and derives session keys. From that point every datagram is encrypted and authenticated. A data channel cannot send plaintext because the layer below it does not carry plaintext. The same DTLS session also exports the keying material that SRTP uses for any media tracks, which is why media and data share one encrypted context.

SCTP runs an association inside the DTLS session. SCTP is what makes a data channel different from a raw UDP socket: it adds message framing so a send arrives as one discrete message rather than a byte stream, congestion control so a fast sender does not swamp the link, and per-stream reliability so each channel can choose its own delivery guarantee. The "channels" you open are SCTP streams inside the one association.

RTCDataChannel is the JavaScript object that wraps one SCTP stream. Creating, configuring, sending, and receiving all happen on this object. Everything below it is automatic once the connection negotiates.

Opening a channel

The peer that calls createDataChannel is the initiator. The method returns a channel object immediately, but the channel is not usable yet. Its readyState starts at connecting. Sending before open throws.

const channel = pc.createDataChannel('chat');
console.log(channel.readyState); // "connecting"
channel.onopen = () => {
  console.log(channel.readyState); // "open"
  channel.send('first message');
};

The first argument is the channel label, a UTF-8 string of up to 65 535 bytes. The label is not unique and not used for routing: it is a human-readable name you read back with channel.label. Two channels can share a label; SCTP keeps them apart by stream id, not by name.

The second argument is RTCDataChannelInit, a dictionary of options. All are optional. The defaults give you a reliable, ordered, in-band-negotiated channel, the same delivery guarantee as TCP.

The RTCDataChannelInit dictionary

Option Type Default Effect
ordered boolean true Deliver messages in send order. Set false to allow out-of-order delivery.
maxRetransmits number null Cap retransmission attempts for a lost message. 0 means never retransmit.
maxPacketLifeTime number (ms) null Drop a message that cannot be delivered within this window.
protocol string '' Subprotocol name; advisory metadata, read back via channel.protocol.
negotiated boolean false false fires ondatachannel on the remote. true means both sides create the channel manually.
id number auto Stream id. Required when negotiated: true; ignored otherwise.

maxRetransmits and maxPacketLifeTime are mutually exclusive. Set at most one. Setting both throws a TypeError. Setting neither gives full reliability: SCTP retransmits until the message arrives or the connection dies.

// Reliable + ordered (the default; TCP-like).
pc.createDataChannel('chat');

// Unreliable + unordered: lowest latency, no retransmit.
pc.createDataChannel('pos', { ordered: false, maxRetransmits: 0 });

// Partially reliable: retransmit, but give up after 300 ms.
pc.createDataChannel('voice-meta', { maxPacketLifeTime: 300 });

Reliability and ordering are independent axes. You can have unordered-but-reliable delivery (every message arrives, in any order) by setting ordered: false and leaving the retransmit options unset. The full matrix and which game pattern each mode suits is covered in reliability and ordering.

The rest of this section takes each option one at a time.

ordered

ordered controls delivery order. It defaults to true. With true, the receiver sees messages in the exact order the sender called send, the same guarantee TCP gives. SCTP buffers a message that arrives early and holds it until the gap before it fills.

That buffering is the cost. On a lossy link, an ordered channel stalls every later message behind a lost one until the retransmission lands: head-of-line blocking. Setting ordered: false lets SCTP deliver each message the moment it arrives, in whatever order, so one loss never delays an unrelated message.

// Ordered (default): step 1 always arrives before step 2.
const log = pc.createDataChannel('log');

// Unordered: each event surfaces as soon as it lands.
const events = pc.createDataChannel('events', { ordered: false });

Use ordered: true when later messages depend on earlier ones: a chat transcript, an incrementing turn counter, a file's chunks. Use ordered: false for independent events where freshness beats sequence: player positions, sensor readings, cursor moves. Order is independent of reliability; an unordered channel still retransmits unless you also cap retransmissions.

maxRetransmits

maxRetransmits caps how many times SCTP retries a message that the receiver did not acknowledge. It defaults to null, which means unlimited retries: full reliability. Set it to a number to make the channel partially reliable.

// Retry at most twice, then drop the message and move on.
const updates = pc.createDataChannel('updates', { maxRetransmits: 2 });

// Never retransmit: fire-and-forget, lowest latency.
const pos = pc.createDataChannel('pos', { maxRetransmits: 0, ordered: false });

maxRetransmits: 0 is the extreme case: the message is sent once and never resent. A lost message is simply gone. That is correct for data that a newer message replaces, where a retransmitted stale value would arrive after the value that superseded it. Any positive number bounds the retry effort while still tolerating occasional loss.

maxPacketLifeTime

maxPacketLifeTime is the time-based sibling of maxRetransmits. It sets a window in milliseconds; SCTP keeps retransmitting a lost message until the window expires, then gives up. It defaults to null (no limit).

// Retransmit for up to 200 ms, then abandon the message.
const audioMeta = pc.createDataChannel('audio-meta', { maxPacketLifeTime: 200 });

The difference from maxRetransmits is what you bound. maxRetransmits bounds effort regardless of how long it takes; maxPacketLifeTime bounds wall-clock latency regardless of how many tries fit in the window. Pick maxPacketLifeTime when data has a freshness deadline: a value useful for 200 ms and worthless after. Pick maxRetransmits when you care about retry count, not the clock.

The two are mutually exclusive. Setting both on one channel throws a TypeError at createDataChannel. Setting neither leaves the channel fully reliable.

Reliability mode maxRetransmits maxPacketLifeTime
Fully reliable unset unset
Partial, bounded by retries n unset
Partial, bounded by time unset ms
Invalid (throws) n ms

protocol

protocol names a subprotocol for the channel, a string both sides can read to agree on the message format running over it. It defaults to the empty string. The browser does not interpret it or enforce it; it is metadata, carried in the channel setup and read back through channel.protocol.

const ch = pc.createDataChannel('sync', { protocol: 'game-state/v2' });
// On either side:
console.log(ch.protocol); // "game-state/v2"

Use it to version your wire format or to distinguish two channels that share a label. The receiver can branch on channel.protocol to pick a parser. It is documentation that travels with the channel, nothing more.

negotiated and id

negotiated selects how the two peers agree the channel exists, and id is the SCTP stream number that binds their two ends together. They work as a pair and get a full treatment in the section on negotiated channels below.

In short: negotiated defaults to false, meaning the initiator opens the channel and the remote receives it through the ondatachannel event, with the browser picking id. Setting negotiated: true means both sides call createDataChannel themselves with a matching id you assign, and no event fires.

// In-band (default): browser assigns id, remote gets ondatachannel.
const a = pc.createDataChannel('chat');

// Pre-negotiated: both sides create with the same fixed id.
const b = pc.createDataChannel('control', { negotiated: true, id: 0 });

When negotiated is false, any id you pass is ignored; the browser assigns one. When negotiated is true, id is required and must match on both peers.

The remote side

When the initiator creates an in-band channel (negotiated: false, the default), the remote peer does not call createDataChannel. Instead the channel arrives as an event.

pc.ondatachannel = (event) => {
  const channel = event.channel;
  channel.onmessage = (e) => handle(e.data);
};

The ondatachannel event fires once per channel the other side opens. The channel it hands you is already configured: it inherited ordered, the reliability mode, protocol, and id from the initiator. You do not set those again; you only attach handlers.

The repository wires this through setupPeer in src/salon/peer.js. The peer connection forwards each incoming channel to a callback:

// src/salon/peer.js
pc.ondatachannel = (e) => {
  emit('ch:incoming', { label: e.channel.label });
  onChannel?.(e.channel);
};

Either side may open a channel. The initiator and the responder roles for the channel are decided per channel, not per connection. The host can open one channel and the guest another on the same connection.

The channel events

A channel exposes a small set of events. Three drive normal life: open, message, close. Two more report flow control and failure: bufferedamountlow and error. Each has both an on<event> property and addEventListener form.

Event Fires when Typical use
open readyState reaches open Start sending; reveal the UI
message A message arrives Parse and dispatch
bufferedamountlow Queued bytes drop below the threshold Resume a paused bulk send
close The channel is shut from either side Tear down, stop the loop
error An SCTP-level failure occurs Log, surface a reconnect prompt

open

open fires once, when the channel transitions from connecting to open. It is the first moment send is safe. Treat it as the start signal: this is where you send your first message, start a game loop, or reveal a connected UI.

channel.onopen = () => {
  console.log(channel.readyState); // "open"
  channel.send(JSON.stringify({ type: 'hello' }));
};

On a pre-negotiated channel the open event still fires on each side once the SCTP stream is usable. On an in-band channel, the initiator's open and the remote's open fire independently as each end's stream comes up.

message

message fires once per received message. The payload is on event.data. Its type depends on what the sender sent and on this channel's binaryType: a string for a text send, and either a Blob or an ArrayBuffer for a binary send.

channel.onmessage = (event) => {
  if (typeof event.data === 'string') {
    handleText(event.data);
  } else {
    handleBinary(event.data); // Blob or ArrayBuffer per binaryType
  }
};

SCTP framing guarantees that one send produces exactly one message. You never reassemble a partial frame the way you would on a raw TCP socket. A large message you chunked yourself arrives as several message events, one per chunk you sent.

bufferedamountlow

bufferedamountlow fires when bufferedAmount falls to or below bufferedAmountLowThreshold, which defaults to 0. It is the resume signal for backpressure: pause sending when the queue is full, and let this event tell you when there is room again. The full loop is in the backpressure section below.

channel.bufferedAmountLowThreshold = 64 * 1024;
channel.onbufferedamountlow = () => resumeSending();

With the threshold left at 0, the event fires only when the send queue fully drains. Set a positive threshold to resume before the pipe runs dry and keep throughput steady.

close

close fires when the channel shuts down, whether you called close(), the other peer did, or the connection dropped. After it fires, readyState is closed and send is a no-op. This is the single teardown point: stop loops, clear timers, release UI here.

channel.onclose = () => {
  stopGameLoop();
  showDisconnected();
};

error

error fires on an SCTP-level failure. The event is an RTCErrorEvent carrying an error field whose errorDetail string names the cause: sctp-failure, sdp-syntax-error, and similar.

channel.onerror = (event) => {
  console.warn('channel error', event.error.errorDetail);
};

Most failures that reach this handler are unrecoverable for the channel; close usually follows. Log the detail for diagnostics and treat the channel as gone.

Centralizing attachment

The repository centralizes the three common handlers in attachChannel. It parses every incoming message as JSON, emits an internal event for the log panel, then forwards to your handlers:

// src/salon/peer.js
export function attachChannel(ch, { onMessage, onOpen, onClose } = {}) {
  ch.onmessage = (e) => {
    let msg;
    try { msg = JSON.parse(e.data); } catch { return; }
    emit('ch:recv', { label: ch.label, msg });
    onMessage?.(msg, ch);
  };
  ch.onopen = () => { emit('ch:open', { label: ch.label }); onOpen?.(ch); };
  ch.onclose = () => { emit('ch:close', { label: ch.label }); onClose?.(ch); };
  return ch;
}

The try/catch around JSON.parse is deliberate. A malformed or non-JSON frame is dropped rather than thrown. Every game in this repository sends JSON, so the parse is the common path; binary payloads would bypass this helper and read e.data directly.

Opening a channel both ways

Putting the two sides together: the initiator creates the channel and waits for open; the responder receives it through ondatachannel. The same attachChannel runs on both.

// Initiator
const pc = setupPeer({ onStateChange });
const channel = pc.createDataChannel('game');
attachChannel(channel, {
  onOpen:    () => startMatch(),
  onMessage: (msg, ch) => route(msg, ch),
});

// Responder
const pc = setupPeer({
  onChannel(channel) {
    attachChannel(channel, {
      onOpen:    () => startMatch(),
      onMessage: (msg, ch) => route(msg, ch),
    });
  },
});

The symmetry is the point. Both peers run the same onMessage logic, so the wire protocol is written once.

Sending data

channel.send(data) accepts four payload types: string, Blob, ArrayBuffer, and any ArrayBufferView such as Uint8Array or Float32Array. The first is text; the other three are binary. send returns nothing; it queues the bytes and returns immediately.

channel.send('a string');                  // text frame
channel.send(new Uint8Array([1, 2, 3]));    // binary frame from a typed array
channel.send(float32Array.buffer);          // binary frame from an ArrayBuffer
channel.send(new Blob([bytes]));            // binary frame from a Blob
Payload type Wire form Arrives as Best for
string UTF-8 text string JSON messages, chat, control
ArrayBuffer raw bytes Blob or ArrayBuffer packed binary, file chunks
ArrayBufferView the view's bytes Blob or ArrayBuffer typed numeric data
Blob raw bytes Blob or ArrayBuffer data already in Blob form

string

A string is sent as UTF-8 and always arrives as a string, regardless of the receiver's binaryType. This is the path for JSON. The repository's send helper stringifies every message:

channel.send(JSON.stringify({ type: 'move', x: 4, y: 7 }));

Strings are the easiest to read in DevTools and the obvious choice for structured control data. The cost is encoding: numbers become decimal text the receiver must parse back. For low-frequency messages that cost is irrelevant.

ArrayBuffer

An ArrayBuffer is a fixed-length block of raw bytes. Sending one transmits its bytes verbatim. This is the form for data you have already packed: a file chunk read with FileReader, or a binary message you built with a DataView.

const header = new ArrayBuffer(8);
new DataView(header).setUint32(0, frameId);
channel.send(header);

ArrayBufferView

An ArrayBufferView (Uint8Array, Float32Array, DataView, and the rest) is a typed window onto an ArrayBuffer. Sending a view transmits exactly the bytes the view spans, not necessarily the whole backing buffer.

const coords = new Float32Array([1.5, 2.5, 3.5]); // 12 bytes
channel.send(coords);        // sends the view's 12 bytes
channel.send(coords.buffer); // sends the backing ArrayBuffer

Here the view and its buffer span the same bytes, so both sends are identical. They diverge when the view is a slice of a larger buffer. view.buffer is the whole underlying buffer, which can be larger than the view. To send only the view's region, send the view itself, or view.slice() to copy just that region.

Blob

A Blob is an immutable chunk of bytes, the form File objects take and the form FileReader and fetch often produce. send accepts a Blob directly, so you can forward file data without first reading it into an ArrayBuffer.

const file = input.files[0]; // a File is a Blob
channel.send(file.slice(0, 16 * 1024)); // send the first 16 KiB

The bytes go out the same as any binary send. On the receiving side they surface as a Blob or ArrayBuffer depending on binaryType, never as a Blob automatically just because the sender used one.

binaryType: reading binary on the other side

The receiving channel decides how binary arrives in e.data. The binaryType property selects the form. It is 'blob' by default; set 'arraybuffer' to get an ArrayBuffer you can wrap in a typed-array view synchronously.

binaryType e.data for a binary frame Read with
'blob' (default) Blob await blob.arrayBuffer(), async
'arraybuffer' ArrayBuffer new Uint8Array(data), synchronous

A Blob defers loading the bytes; you read them asynchronously, which suits large file data you intend to write to disk anyway. An ArrayBuffer hands you the bytes immediately, which suits small fixed-layout messages you decode on the spot. For per-frame game data, 'arraybuffer' avoids an await on the hot path.

channel.binaryType = 'arraybuffer';

channel.onmessage = (e) => {
  if (typeof e.data === 'string') {
    handleText(e.data);
    return;
  }
  const view = new Float32Array(e.data); // e.data is an ArrayBuffer
  applyPosition(view[0], view[1], view[2]);
};

Set binaryType before the first binary message arrives; setting it after has no effect on frames already delivered. A text send always arrives as a string regardless of binaryType; the property only governs binary frames.

Binary is worth it for high-frequency numeric data. A 3D coordinate as JSON, {"x":1.23,"y":4.56,"z":7.89}, is roughly 30 bytes of text the receiver must parse. The same three values in a Float32Array are exactly 12 bytes and need no parsing. At 60 updates per second the difference compounds. For text-shaped data (chat, control messages, lobby state), JSON is fine and easier to debug.

binaryType is per channel

Each channel has its own binaryType. A connection can carry a JSON chat channel left at 'blob' and a position channel set to 'arraybuffer' at the same time. Set it on the channel you read binary from.

Message size and chunking

SCTP does not impose a fixed application-level message size on its own, but practical limits exist and they bite. A single send is not the place to push a whole file.

The first limit is interoperability. The connection negotiates a maximum message size and advertises it in the SDP. Older stacks settled on a conservative 16 KiB ceiling, and many implementations still cap a single reliable send near 256 KiB. Sending one message larger than the negotiated maximum throws or silently fails, depending on the browser. You can read the negotiated ceiling from pc.sctp.maxMessageSize once the connection is up.

console.log(pc.sctp.maxMessageSize); // negotiated max bytes per message

The second limit is head-of-line behavior. A message larger than the path MTU (commonly around 1200 bytes for a WebRTC datagram) is fragmented by SCTP into multiple packets. On a reliable ordered channel, losing one fragment stalls every later message until the retransmission arrives. A large message turns a single loss into a long pause.

The MTU limit

The Maximum Transmission Unit for most WebRTC connections is around 1200 bytes. A message larger than that fragments into several SCTP packets. On a reliable ordered channel, one lost fragment blocks every message behind it until SCTP retransmits: the head-of-line stall.

The third reason to keep messages small is responsiveness. A single multi-megabyte send monopolizes the SCTP association until it drains, delaying every other channel and message behind it. Smaller messages interleave, so a file transfer and a chat message share the link instead of one starving the other.

The fix is to chunk. Split a large payload into fixed-size pieces, send each as its own message, and reassemble on the far side. A 16 KiB chunk size is a safe default that clears the size ceiling and keeps each message close to the MTU's multiple. Chunking also lets you report progress and interleave other traffic.

const CHUNK = 16 * 1024;
for (let offset = 0; offset < buffer.byteLength; offset += CHUNK) {
  channel.send(buffer.slice(offset, offset + CHUNK));
}

This naive loop ignores backpressure: it queues every chunk at once and can blow up memory. The next section fixes that. The complete pattern (chunk sizing, sequence numbering, reassembly, integrity checks, and resumable transfers) is the subject of file transfer chunking, and the same flow control is generalized in reliability and ordering.

Backpressure: bufferedAmount

send does not block. It queues the bytes in an internal SCTP send buffer and returns. If you call send faster than the network drains, the buffer grows without bound until the browser kills the channel or the tab runs out of memory.

channel.bufferedAmount reports the byte count still queued and not yet handed to the network. Read it before sending and pause when it climbs.

const HIGH = 256 * 1024; // pause above 256 KiB queued

function trySend(data) {
  if (channel.bufferedAmount > HIGH) return false; // skip or defer
  channel.send(data);
  return true;
}

For a steady stream of disposable updates (player positions), dropping a frame when the buffer is full is correct; the next frame supersedes it. For a file transfer you must not drop, so you pause and resume instead.

Resuming on a timer wastes cycles. The channel offers an event. Set bufferedAmountLowThreshold to a byte level, and bufferedamountlow fires when the queue drains below it.

channel.bufferedAmountLowThreshold = 64 * 1024;
channel.onbufferedamountlow = () => pumpNextChunk();

function pumpNextChunk() {
  while (chunks.length && channel.bufferedAmount < HIGH) {
    channel.send(chunks.shift());
  }
}

This is the flow-control loop for any bulk transfer: fill until bufferedAmount reaches the high mark, stop, and let bufferedamountlow restart the pump. It keeps the pipe full without unbounded memory growth.

The two strategies (drop versus pause) come from the nature of the data, not the channel. Disposable data drops:

// Disposable: skip this frame if the pipe is backed up.
function sendSnapshot(snapshot) {
  if (state.bufferedAmount > 256 * 1024) return; // next snapshot supersedes it
  state.send(snapshot);
}

Irreplaceable data pauses:

// Bulk file: queue chunks, drain under backpressure, resume on the event.
let queue = makeChunks(file);
channel.bufferedAmountLowThreshold = 64 * 1024;
channel.onbufferedamountlow = pump;

function pump() {
  while (queue.length && channel.bufferedAmount < 256 * 1024) {
    channel.send(queue.shift());
  }
  if (!queue.length) channel.onbufferedamountlow = null;
}
pump(); // prime the loop

The threshold sits below the high mark so the event fires while there is still data in flight, not after the pipe empties. That overlap keeps throughput steady. The deeper treatment (windowing, acknowledgement, and resumable transfers) is in reliability and ordering and file transfer chunking.

The readyState lifecycle

A channel moves through four states in one direction: connectingopenclosingclosed. It never moves backward. You read the current state from channel.readyState.

State Meaning send Entered
connecting Created, transport not ready Throws InvalidStateError At createDataChannel
open Ready to send and receive Works Fires the open event
closing Shutting down, queue still flushing No-op close() called, or peer closed
closed Fully shut down No-op Fires the close event
connecting open closing closed open event close() close event

A channel starts in connecting. For an in-band channel the initiator's channel is connecting until the SCTP stream comes up; for a pre-negotiated channel each side is connecting until its own stream is ready. The transition to open fires the open event, the one safe point to begin sending.

closing is brief. It starts when either side calls close() or when the connection begins tearing down, and lasts only while any already-queued bytes flush. No new sends go out in closing; the call is a silent no-op. When flushing finishes, the state reaches closed and the close event fires.

The trap is the gap between creating a channel and the open event. Code that creates a channel and immediately sends throws InvalidStateError, because the state is still connecting. Always send from inside the open handler or after confirming the state.

The repository guards every send at the boundary. send in src/salon/protocol.js checks the state and refuses to throw:

// src/salon/protocol.js
export function send(channel, msg) {
  if (channel?.readyState !== 'open') return false;
  channel.send(JSON.stringify(msg));
  emit('wire:send', { label: channel.label, msg });
  return true;
}

The ?. also tolerates a null channel, useful before the connection exists. The function returns a boolean so callers can tell whether the message left. broadcast builds on it, sending to many channels and counting the successes:

// src/salon/protocol.js
export function broadcast(channels, msg) {
  let n = 0;
  for (const ch of channels) {
    if (send(ch, msg)) n++;
  }
  emit('wire:broadcast', { count: n, msg });
  return n;
}

A host running three guests calls broadcast([ch1, ch2, ch3], state). A guest mid-disconnect whose channel is closing is skipped silently; the others still receive the state. The guard turns a half-open peer set into a non-event instead of a thrown exception in the game loop.

Negotiated channels: in-band vs pre-negotiated

A channel must agree on its stream id and settings between both peers. There are two ways to reach that agreement, selected by the negotiated option.

In-band (negotiated: false): the default

The initiator calls createDataChannel. The browser signals the new channel over the existing SCTP association using an internal control message (the DCEP open). The remote peer's ondatachannel fires with a fully configured channel. The remote writes no createDataChannel call.

This is the simplest path and what every game in this repository uses. One side opens, the other listens.

// Initiator
const ch = pc.createDataChannel('game'); // negotiated: false by default

// Responder
pc.ondatachannel = (e) => attachChannel(e.channel, handlers);

Pre-negotiated (negotiated: true): fixed id, both sides create

Both peers call createDataChannel with negotiated: true and the same id. No DCEP message is exchanged; ondatachannel never fires. Each side assembles its end independently, and the matching id binds them to the same SCTP stream.

// Both peers run this exact code.
const control = pc.createDataChannel('control', { negotiated: true, id: 0 });
attachChannel(control, handlers);

The id must match and must be unique per channel. Mismatched ids produce two unconnected channels that look open but never exchange a byte.

In-band (false) Pre-negotiated (true)
Who creates Initiator only Both peers
Remote signal ondatachannel fires No event
id Assigned by the browser You assign, must match
Best for Most apps; channels opened on demand Fixed channels both sides know up front

Pre-negotiated channels suit a fixed set of well-known channels: a control channel and a data channel both peers create at startup. They remove the asymmetry of waiting for ondatachannel and avoid a race where one side sends before the other has its handler attached. In-band channels suit anything opened dynamically during the session.

Multiple channels on one connection

Because channels multiplex over one SCTP association, opening several is cheap and common. Each channel keeps its own reliability mode, ordering, and binaryType.

A typical split for a real-time game:

// Control: reliable + ordered. Match start, chat, scores.
const control = pc.createDataChannel('control');

// State: unreliable + unordered. 60 Hz position snapshots.
const state = pc.createDataChannel('state', { ordered: false, maxRetransmits: 0 });
state.binaryType = 'arraybuffer';

A dropped position snapshot does not matter (the next one supersedes it), so state skips retransmission for latency. A dropped score update would corrupt the game, so control stays reliable. Separating them onto two channels means a lost snapshot never delays a score, and a retransmitted score never delays a snapshot. They cannot block each other; they are independent SCTP streams.

A second reason to split is parsing. Routing all traffic over one channel forces every message through one dispatcher. Two channels let you attach two onMessage handlers and skip the discriminator on hot paths.

Dispatching messages by type

Once messages arrive, you need to route each to the right handler. The repository encodes every message as a JSON object with a type (or short t) field, then maps the field to a function.

dispatcher in src/salon/protocol.js builds that map into a single onMessage callback:

// src/salon/protocol.js
export function dispatcher(handlers) {
  return (msg, channel) => {
    const key = msg?.type ?? msg?.t;
    const fn = key !== undefined ? handlers[key] : undefined;
    if (fn) fn(msg, channel);
  };
}

You hand it a table keyed by message type and pass the result straight to attachChannel:

const onMessage = dispatcher({
  fire:   (msg, ch) => registerShot(msg.cell),
  result: (msg)     => applyResult(msg.cell, msg.kind),
  chat:   (msg)     => appendChat(msg.text),
});

attachChannel(channel, { onMessage, onOpen, onClose });

A message whose type has no handler is ignored, not thrown. That tolerance lets one side add a new message type before the other ships support for it; old peers drop what they do not understand. The discriminator design, versioning, and the trade-off between fat messages and many small ones are covered in wire protocols. The RTCDataChannel API surface itself is catalogued in the reference.

A binary example end to end

Combining binaryType, a typed array, and a dispatcher for a 60 Hz position channel:

// Sender: pack three floats, no JSON.
const buf = new Float32Array(3);
function sendPosition(x, y, z) {
  buf[0] = x; buf[1] = y; buf[2] = z;
  if (state.bufferedAmount < 64 * 1024) state.send(buf.buffer);
}

// Receiver: unpack synchronously.
state.binaryType = 'arraybuffer';
state.onmessage = (e) => {
  const p = new Float32Array(e.data);
  remote.x = p[0]; remote.y = p[1]; remote.z = p[2];
};

Twelve bytes per update, no allocation on the send side beyond the reused buffer, and no parse on the receive side. This is the shape every fast-twitch multiplayer channel converges on.

Closing a channel

Call channel.close() to shut one channel without touching the connection. The state moves to closing, queued bytes flush, then close fires on both ends and the state reaches closed. Other channels on the same connection stay open.

Closing the RTCPeerConnection with pc.close() tears down every channel at once; each fires its close event. A channel also closes when the connection drops: a transport failure surfaces as a close on every channel.

Treat close as the single teardown point. Stop the game loop, clear timers, and release the UI there, whether the close came from a deliberate close() or a dropped connection.

Recap

RTCDataChannel is an encrypted peer-to-peer byte pipe over SCTP. The defaults give TCP-like reliable ordered delivery; the options trade that for latency.

  • Create with pc.createDataChannel(label, init); send from inside the open handler.
  • ordered, maxRetransmits, and maxPacketLifeTime set the delivery guarantee per channel. Set at most one of the latter two.
  • The remote receives an in-band channel through ondatachannel. Pre-negotiated channels (negotiated: true, matching id) skip that event and are created on both sides.
  • send accepts string, Blob, ArrayBuffer, and ArrayBufferView. Set binaryType = 'arraybuffer' to read binary synchronously.
  • Chunk messages larger than ~16 KiB and watch bufferedAmount to avoid head-of-line stalls and unbounded buffering.
  • Guard every send on readyState === 'open', as protocol.js does, and route incoming messages with a type-keyed dispatcher.

Going further

Troubleshooting

Symptom Cause Fix
send throws InvalidStateError Channel state is connecting Send from the open handler or guard on readyState === 'open'.
Remote never sees the channel negotiated: true on one side only, or mismatched id Match negotiated and id on both sides, or use the in-band default.
ondatachannel never fires Channel was pre-negotiated (negotiated: true) Pre-negotiated channels create on both sides; there is no event.
e.data is a Blob, not an ArrayBuffer binaryType left at default Set channel.binaryType = 'arraybuffer' before the first binary frame.
Large message fails or throws Exceeds the negotiated max message size Chunk into ~16 KiB pieces and reassemble.
Memory climbs, channel dies under load Sending faster than the network drains Pause when bufferedAmount is high; resume on bufferedamountlow.
Late messages stall behind one loss Large messages on a reliable ordered channel Chunk, or move disposable data to an unordered channel.
Incoming JSON silently dropped JSON.parse threw on a non-JSON frame Confirm the sender encodes JSON; binary frames bypass the parse helper.