RTCIOBroadcastChannel
const chat = socket.createChannel("chat", { ordered: true });
A broadcast channel is a logical DataChannel shared with every connected peer (and any peer that joins later). Internally it's a Map<peerId, RTCIOChannel> — one per-peer DataChannel under the hood, fanned out by the broadcast wrapper.
Construction
You don't construct it directly. Call socket.createChannel(name, options) — the library returns either a fresh instance or the existing one if the name is already registered.
const chat = socket.createChannel("chat", { ordered: true });
const game = socket.createChannel("game-events", { ordered: false, maxRetransmits: 0 });
The name is registered in an internal _channelDefs list, so peers that join later automatically get a matching channel attached.
Sending
emit(name, ...args)
chat.emit(eventName: string, ...args: any[]): void
Fans the JSON envelope out to every peer's per-peer channel:
chat.emit("msg", { user: "alice", text: "hi" });
Synchronous. Doesn't wait for delivery.
send(data)
chat.send(data: ArrayBuffer | string): boolean
Raw bytes, fanned out. Returns true only if every peer's per-peer send returned true; if any peer was queueing, returns false.
You probably won't send raw bytes on a broadcast channel — chunked file transfer is better as a per-peer thing. But it works if you have a use case.
Receiving
on(event, handler) / off(event, handler) / once(event, handler)
chat.on(event: string, handler: (...args: any[]) => void): this
Registers a listener that fires for the named event from any peer's per-peer channel underneath. The handler doesn't receive the sender's id directly — if you need it, include it in the payload.
chat.on("msg", (msg) => {
console.log(msg.user, "said", msg.text);
});
Special events dispatched by the broadcast wrapper:
| Event | Args | Fires when |
|---|---|---|
peer-left | (peerId) | One peer's underlying channel closed |
close | none | All peers gone (auto-fires when the last per-peer channel closes), or you called chat.close() |
error | (err) | Some per-peer channel raised an error |
drain | none | A per-peer channel fired drain (so you can resume sending) |
Plus any user event name you've emited to it.
chat.on("peer-left", (peerId) => console.log("lost", peerId));
chat.on("close", () => console.log("everyone left"));
peer-left is the broadcast-channel-scoped equivalent of socket.on("peer-disconnect"). It fires only for the per-peer channel underneath, not the whole peer connection.
State
peerCount: number
Live property. Number of peers currently attached to this broadcast channel.
console.log(`${chat.peerCount} people in the channel`);
closed: boolean
Live property. True after chat.close() has been called. A closed broadcast channel won't accept new peers.
Closing
close()
chat.close(): void
Closes every per-peer channel and prevents future late joiners from being attached. Fires close once.
chat.close();
chat.peerCount; // 0
chat.closed; // true
Late joiner attachment
When a new peer's connection comes up:
- The library walks
_channelDefs(the registry of{ name, options }you've created broadcast channels with). - For each one, it creates a matching per-peer DataChannel to the new peer.
- The broadcast channel's
_addPeer(peerId, channel)is called, which:- Saves the channel in the broadcast's peer map.
- Replays every
on(event, handler)subscription onto the new channel (so handlers fire for the new peer's traffic too). - Wires
drain,error,closefrom the per-peer channel up to the broadcast.
You don't write any of this — it's automatic. You just call socket.createChannel("chat") once at startup.
Patterns
Chat channel
// Both peers run this on init.
const chat = socket.createChannel("chat", { ordered: true });
chat.on("msg", (msg) => append(msg));
// Send.
chat.emit("msg", { user: userName, text: input.value, time: Date.now() });
Cursor positions (unreliable, frequent)
const cursors = socket.createChannel("cursor", {
ordered: false,
maxRetransmits: 0,
});
cursors.on("pos", ({ peerId, x, y }) => updateCursor(peerId, x, y));
window.addEventListener("mousemove", (e) => {
cursors.emit("pos", { peerId: socket.id, x: e.clientX, y: e.clientY });
});
maxRetransmits: 0 and ordered: false give you the lowest-latency, lossy delivery — perfect for "show roughly where everyone's mouse is" semantics. Lost packets just mean a slightly stale cursor.
Game state
const game = socket.createChannel("game", { ordered: true });
game.on("score", ({ peerId, delta }) => bumpScore(peerId, delta));
game.on("peer-left", (peerId) => removeFromScoreboard(peerId));
Re-using on different connections
The broadcast channel is per-Socket, not per-app. If you create a Socket, then create a second Socket (e.g. for a separate room), each one has its own _channelDefs and its own broadcast channels. The broadcast wrapper does not span sockets.
Limits
- The broadcast channel itself isn't a separate SCTP stream — it's
peerCountseparate streams, each with its own buffered amount.chat.send(buf)may queue on some peers and not others. peer-leftdoesn't tell you why — could be a clean leave, an ICE failure, or a tab close. If you need the reason, listen tosocket.on("peer-disconnect")instead.- A peer that
createChannels a name without you havingcreateChannel-ed it on your side gets a one-sided channel: their sends don't reach you. Both sides need the samecreateChannel("name")call. Broadcast channels handle this automatically for the broadcast registration — but if you want per-peer custom channels, seesocket.peer().createChannel.
Live examples
Ordered, reliable — chat
The classic shape: every peer's text shows up in every other peer's log.
Unordered, lossy — cursor sync
Pass { ordered: false, maxRetransmits: 0 } and the SCTP transport stops queuing or retransmitting — the latest cursor position wins, stale frames are dropped on the floor. Right shape for game state, presence indicators, anything where the next packet is more useful than the last.