Skip to main content

DataChannels

DataChannels are how two peers exchange arbitrary bytes (or strings, or JSON envelopes) once a connection is up. rtc.io exposes them in three flavors:

FlavorAPIUse
Ctrl channelsocket.emit / socket.onImplicit; one channel per peer; carries socket.emit user events
Broadcast channelsocket.createChannel(name)One logical channel shared with every peer (and any peer that joins later)
Per-peer channelsocket.peer(id).createChannel(name)A named channel just between you and one peer

This page covers what's happening under each.

The ctrl channel

Every peer connection in rtc.io starts with a built-in DataChannel called rtcio:ctrl, opened with negotiated: true, id: 0, ordered: true. Both sides create it independently with the same id, so there's no DC-OPEN handshake — it's open as soon as SCTP is up. That's also why peer-connect fires when the ctrl channel opens: it's the canonical "this peer is reachable for traffic" signal.

socket.emit('event', ...args) and socket.peer(id).emit('event', ...args) both go over this channel as JSON envelopes:

{ e: "event-name", d: [arg1, arg2, ...] }

The receiving side parses, then dispatches to:

  1. Global listeners registered with socket.on('event-name', ...)
  2. Per-peer listeners registered with socket.peer(senderId).on('event-name', ...)

Reserved event names (peer-connect, peer-disconnect, track-added, anything starting with #rtcio:) are filtered on receive — peers can't spoof them, only your local socket can fire them.

Custom channels: the negotiated:true model

When you call socket.createChannel('chat', { ordered: true }), rtc.io creates a DataChannel with:

peer.connection.createDataChannel("rtcio:ch:chat", {
negotiated: true,
id: hashChannelName("chat"), // deterministic
ordered: true,
});

negotiated: true means: don't run the in-band DC-OPEN handshake; assume both sides already know about this channel. The id is the SCTP stream id; both sides must pick the same one or messages won't pair up.

We pick that id by hashing the name (FNV-1a) modulo 1023, then +1:

rtc.ts (excerpt)
function hashChannelName(name: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return ((h >>> 0) % 1023) + 1; // [1, 1023] — id 0 is reserved for ctrl
}

The 1023 cap is Chromium's kMaxSctpStreams — a higher id throws OperationError: RTCDataChannel creation failed. Firefox is more permissive but we pick the lowest common denominator.

Why hash? Because both sides need the same id without an extra round-trip. A name-based hash means socket.createChannel("chat") on every peer produces the same id. No coordination, no signaling.

Hash collisions

Two distinct channel names that happen to hash to the same id would collide. rtc.io checks for this on every createChannel and throws a clear error:

[rtc-io] Channel 'sloths' hash-collides with existing channel 'foo' on peer abc123
(both names hash to SCTP id 47). Pick a different channel name.

With ~30 channel names you have ~50% chance of a collision (birthday paradox over 1023 slots). For most apps you're nowhere near that, but if you start hitting collisions, rename the channels — the error message tells you which.

Broadcast channels

const chat = socket.createChannel("chat", { ordered: true });
chat.on("msg", (text) => append(text));
chat.emit("msg", "hello everyone");

socket.createChannel returns an RTCIOBroadcastChannel. Internally it tracks a Map<peerId, RTCIOChannel> — one per-peer channel under the hood, fanned out by emit.

It also adds itself to a _channelDefs registry so that any peer who joins later automatically gets a matching channel attached and bound to the broadcast object's listeners. You don't have to do anything for late-joiner support — call socket.createChannel("chat") once at startup and it covers everyone.

Events:

  • open / close / error / drain — same as a single RTCIOChannel, dispatched per-peer (the broadcast channel forwards them).
  • peer-left (special) — fires when one peer's underlying channel closes (e.g. they disconnected). The broadcast channel itself stays open as long as at least one peer is on it.
chat.on("peer-left", (peerId) => console.log("lost", peerId));
chat.on("msg", (text) => append(text)); // same handler for all peers

Closing the broadcast (chat.close()) closes every peer channel and prevents future late joiners from being attached.

Per-peer channels

const file = socket.peer(targetId).createChannel("file", { ordered: true });
file.on("open", () => console.log("ready"));

socket.peer(id).createChannel returns an RTCIOChannel directly — no broadcast wrapper. This is the right shape for things like file transfer, RPC, or per-pair coordination where you don't want every peer to receive your bytes.

For the channel to actually carry traffic, both sides must call createChannel with the same name. Otherwise the SCTP transport drops messages on the receive side because no one's listening on that stream id.

A common pattern: open the per-peer channel from peer-connect:

socket.on("peer-connect", ({ id }) => {
const file = socket.peer(id).createChannel("file", { ordered: true });
attachFileReceiver(file, ...);
});

Both sides run this, so both sides create the channel with the same hash id. The negotiated:true model takes care of the rest.

ChannelOptions

OptionDefaultEffect
orderedtrueIn-order delivery. Set false to allow lower-latency, possibly out-of-order delivery (good for interactive things like cursor positions).
maxRetransmitsunlimitedNumber of retransmission attempts before giving up on a packet. Mutually exclusive with maxPacketLifeTime.
maxPacketLifeTimeunlimitedMaximum ms to keep retrying a packet. Mutually exclusive with maxRetransmits.
queueBudget1 MBLibrary-side cap on bytes buffered before the channel is open (or while above the high watermark). Not passed to RTCDataChannel.
highWatermark16 MBbufferedAmount threshold above which send() returns false and the library queues. Library-only; the browser doesn't expose this as a constructor option.
lowWatermark1 MBbufferedAmount value at which 'drain' fires. Forwarded to RTCDataChannel.bufferedAmountLowThreshold. Must be < highWatermark.

maxRetransmits and maxPacketLifeTime are mutually exclusive — if both are set the browser ignores one. Use one or the other for unreliable channels.

A telemetry channel that prefers freshness over reliability:

const telemetry = socket.createChannel("cursor", {
ordered: false,
maxRetransmits: 0,
});

A reliable, ordered file channel with a smaller queue budget:

const file = socket.peer(id).createChannel("file", {
ordered: true,
queueBudget: 4 * 1024 * 1024, // 4 MB
});

Reading and writing

// Send a structured event (JSON envelope, like socket.io).
chan.emit("msg", { user: "alice", text: "hi" });

// Send raw bytes or a raw string. Returns false if the channel is queueing.
chan.send(arrayBuffer);

Receive sides:

chan.on("msg", (payload) => { ... }); // for emit/JSON envelopes
chan.on("data", (buf: ArrayBuffer) => { ... }); // for send (binary or string)
chan.on("open", () => console.log("ready"));
chan.on("close", () => console.log("gone"));
chan.on("error", (e) => console.error(e));
chan.on("drain", () => console.log("buffer drained, safe to keep sending"));

send is the right call for streaming binary blobs (file chunks, codec output). emit is for typed application messages. They use the same wire transport but the dispatch is different on the receive side: emit-ed envelopes go to the named event listener, raw send payloads go to 'data'.

When to use which

  • socket.emit('user-event', ...) — quick, ergonomic, broadcasts to every peer. Right for chat, presence, room state.
  • socket.peer(id).emit('user-event', ...) — same, but targeted. Right for per-peer RPC, "you specifically pinged me back."
  • Broadcast channel — when you have a stream of structured events that's the same shape for everyone, especially if you want a peer-left hook or fine-grained backpressure semantics.
  • Per-peer channel — when traffic is genuinely 1:1 (file transfer, large blobs). The broadcast wrapper would just fan out and waste bandwidth.

For "should I emit on socket or on a custom channel" the practical answer is: start with socket.emit. If you need flow control, ordering tweaks, binary, or a peer-left event — graduate to a custom channel.

All four shapes, runnable

Each embed below is a tiny self-contained app — click Open 2nd tab ↗ inside the preview and you'll see the channel come up between the two tabs.

Ordered, reliable broadcast — chat

Broadcast chat
One createChannel('chat'), every peer shares it.
src/main.ts
import io from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Broadcast chat · room <code>${ROOM}</code></h1>
<p><small>Every peer (and any peer that joins later) shares one DataChannel.</small></p>
<div id="log" style="height:280px;overflow:auto;background:#0a0908;border:1px solid var(--line);border-radius:8px;padding:10px;font-family:ui-monospace,monospace;font-size:13px"></div>
<form id="form" style="display:flex;gap:8px;margin-top:10px">
<input id="msg" placeholder="say hi…" autocomplete="off" />
<button type="submit">Send</button>
</form>
<p style="margin-top:10px"><small>Joined as <code>${NAME}</code> · click <strong>Open 2nd tab ↗</strong> to chat with yourself.</small></p>
</div>`;

const log = document.getElementById('log')!;
const form = document.getElementById('form') as HTMLFormElement;
const msg = document.getElementById('msg') as HTMLInputElement;

const append = (line: string, dim = false) => {
const row = document.createElement('div');
row.textContent = line;
if (dim) row.style.opacity = '0.55';
log.appendChild(row);
log.scrollTop = log.scrollHeight;
};

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// One broadcast channel, every peer shares it. Late joiners are auto-included
// because the library replays `_channelDefs` on each new peer connection.
const chat = socket.createChannel('chat', { ordered: true });

chat.on('msg', (m: { name: string; text: string }) => {
append(`${m.name}: ${m.text}`);
});

socket.on('peer-connect', ({ id }) => append(`${id.slice(-4)} joined`, true));
socket.on('peer-disconnect', ({ id }) => append(`${id.slice(-4)} left`, true));

form.addEventListener('submit', (e) => {
e.preventDefault();
const text = msg.value.trim();
if (!text) return;
chat.emit('msg', { name: NAME, text });
append(`you: ${text}`);
msg.value = '';
});

Targeted per-peer — RPC

socket.peer(id).emit / .on
Send to one peer, get a reply back. Click 'Open 2nd tab ↗' inside the preview, then click Ping.
src/main.ts
import io from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Per-peer messaging · room <code>${ROOM}</code></h1>
<p><small>RPC over <code>socket.peer(id).emit/on</code> — message goes to one peer, not all.</small></p>
<div id="peers" style="display:flex;flex-direction:column;gap:8px"></div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>. Click <strong>Open 2nd tab ↗</strong> to bring a peer online.</small></p>
</div>`;

const peersBox = document.getElementById('peers')!;
const renderPeer = (id: string) => {
const row = document.createElement('div');
row.id = `peer-${id}`;
row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:10px;background:rgba(0,0,0,.25);border:1px solid var(--line);border-radius:8px';
row.innerHTML = `
<code style="flex:1">peer ${id.slice(-6)}</code>
<button data-ping="${id}">Ping</button>
<span data-status="${id}" style="opacity:.7"></span>`;
peersBox.appendChild(row);
};
const dropPeer = (id: string) => document.getElementById(`peer-${id}`)?.remove();

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// Library lifecycle event — fires when the ctrl DataChannel to the peer opens,
// which is the moment `socket.peer(id).emit` becomes deliverable.
socket.on('peer-connect', ({ id }) => {
renderPeer(id);
// Send the new peer our hello on connect.
socket.peer(id).emit('hello', { name: NAME });
// Listen for their replies to our pings.
socket.peer(id).on('pong', (data: { rtt: number }) => {
document.querySelector(`[data-status="${id}"]`)!.textContent =
`pong · ${data.rtt.toFixed(1)} ms`;
});
});

socket.on('peer-disconnect', ({ id }) => dropPeer(id));

// Global handlers — fire for messages from ANY peer.
socket.on('hello', (m: { name: string }) => console.log('hello from', m.name));
socket.on('ping', function (this: any, payload: { sentAt: number; from: string }) {
// Reply directly to the sender.
socket.peer(payload.from).emit('pong', {
rtt: performance.now() - payload.sentAt,
});
});

peersBox.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const id = target.dataset.ping;
if (!id) return;
socket.peer(id).emit('ping', { sentAt: performance.now(), from: socket.id });
document.querySelector(`[data-status="${id}"]`)!.textContent = 'sent…';
});

Per-peer with backpressure — file transfer

File transfer with backpressure
16 KB chunks, send() / drain. Same approach scales to GB-sized files.
src/main.ts
import io, { RTCIOChannel } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>File transfer · room <code>${ROOM}</code></h1>
<p><small>Per-peer ordered DataChannel · respects backpressure via <code>send() === false</code> &amp; <code>'drain'</code>.</small></p>
<input id="file" type="file" />
<progress id="prog" max="100" value="0" style="width:100%;margin-top:10px;display:none"></progress>
<p id="status"><small>Click <strong>Open 2nd tab ↗</strong> to bring a peer online.</small></p>
<div id="received" style="margin-top:14px;display:flex;flex-direction:column;gap:8px"></div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;

const fileInput = document.getElementById('file') as HTMLInputElement;
const prog = document.getElementById('prog') as HTMLProgressElement;
const status = document.getElementById('status')!;
const received = document.getElementById('received')!;

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
socket.server.emit('join-room', { roomId: ROOM, name: NAME });

const channels = new Map<string, RTCIOChannel>();

socket.on('peer-connect', ({ id }) => {
// Both sides call createChannel('file'); negotiated:true means each end
// describes the same SCTP stream id in its initial SDP, so the channel is
// open without a DC-OPEN handshake.
const ch = socket.peer(id).createChannel('file', { ordered: true });
channels.set(id, ch);
attachReceiver(ch);
status.innerHTML = `<small>Peer ${id.slice(-4)} ready · pick a file to send.</small>`;
});

socket.on('peer-disconnect', ({ id }) => {
channels.delete(id);
if (channels.size === 0) status.innerHTML = '<small>No peers connected.</small>';
});

interface FileMeta { tid: string; name: string; size: number; mime: string }

function attachReceiver(channel: RTCIOChannel) {
let state: { meta: FileMeta; chunks: ArrayBuffer[]; bytes: number } | null = null;

channel.on('meta', (meta: FileMeta) => {
state = { meta, chunks: [], bytes: 0 };
});

channel.on('data', (chunk: ArrayBuffer) => {
if (!state) return;
state.chunks.push(chunk);
state.bytes += chunk.byteLength;
});

channel.on('eof', () => {
if (!state) return;
const blob = new Blob(state.chunks, { type: state.meta.mime });
const url = URL.createObjectURL(blob);
const row = document.createElement('a');
row.href = url;
row.download = state.meta.name;
row.textContent = `📥 ${state.meta.name} (${(blob.size/1024).toFixed(1)} KB) — click to download`;
row.style.cssText = 'color:var(--accent);text-decoration:underline';
received.appendChild(row);
state = null;
});
}

fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
if (channels.size === 0) {
alert('No peers connected — click "Open 2nd tab ↗" first.');
return;
}
prog.style.display = 'block';
prog.value = 0;

const tid = crypto.randomUUID();
const CHUNK = 16 * 1024;

for (const [, channel] of channels) {
channel.emit('meta', { tid, name: file.name, size: file.size, mime: file.type });
}

let sent = 0;
for (let off = 0; off < file.size; off += CHUNK) {
const buf = await file.slice(off, off + CHUNK).arrayBuffer();
for (const [, channel] of channels) {
// send() returning false means the chunk was queued. Wait for the
// 'drain' event before pushing more — this is the entire backpressure
// contract.
if (!channel.send(buf)) {
await new Promise<void>((r) => channel.once('drain', () => r()));
}
}
sent += buf.byteLength;
prog.value = Math.round((sent / file.size) * 100);
}

for (const [, channel] of channels) channel.emit('eof', { tid });
status.innerHTML = `<small>Sent <strong>${file.name}</strong> to ${channels.size} peer(s).</small>`;
});

Unordered, lossy — game/cursor state

Unordered DataChannel — cursor sync
ordered:false + maxRetransmits:0 = stale frames dropped. Latest wins.
src/main.ts
import io, { type RTCIOBroadcastChannel } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Unordered DataChannel · room <code>${ROOM}</code></h1>
<p><small>Move your mouse over the dark canvas. <code>{ ordered: false, maxRetransmits: 0 }</code> means
the latest position wins — stale frames don't queue up.</small></p>
<div id="canvas" style="position:relative;height:380px;background:#0a0908;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:crosshair">
</div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;

const canvas = document.getElementById('canvas')!;

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// ordered: false + maxRetransmits: 0 = unreliable, unordered SCTP — this
// is the right shape for cursor positions, game state, etc. The library
// uses the same negotiated:true scheme internally; both sides match by name.
const cursors: RTCIOBroadcastChannel = socket.createChannel('cursors', {
ordered: false,
maxRetransmits: 0,
});

const dots = new Map<string, HTMLDivElement>();
const dotFor = (id: string) => {
let d = dots.get(id);
if (d) return d;
d = document.createElement('div');
d.style.cssText = 'position:absolute;width:14px;height:14px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent);pointer-events:none;transition:transform 60ms linear;transform:translate(-50%,-50%)';
canvas.appendChild(d);
dots.set(id, d);
return d;
};

cursors.on('move', (m: { id: string; x: number; y: number }) => {
const d = dotFor(m.id);
d.style.left = m.x + 'px';
d.style.top = m.y + 'px';
});

socket.on('peer-disconnect', ({ id }) => {
dots.get(id)?.remove();
dots.delete(id);
});

let last = 0;
canvas.addEventListener('mousemove', (e) => {
const now = performance.now();
if (now - last < 16) return; // rough 60fps cap before backpressure does it for us
last = now;
const r = canvas.getBoundingClientRect();
cursors.emit('move', { id: socket.id, x: e.clientX - r.left, y: e.clientY - r.top });
});