Example — P2P Data Channel
Two browsers exchange low-latency data via an RTCDataChannel opened through the remote.pc escape hatch. Demonstrates the reconnect-aware DataChannel pattern.
npm: @metered-ca/peer
What it demonstrates
- Opening an
RTCDataChannelviaremote.pc.createDataChannel(...)for true P2P data - The
state-change→"connected"pattern that survives reconnects - The
DataChannelwrapper for backpressure-aware sends - Coexistence with server-routed
peer.sendfor setup messages
Running it locally
Assemble the snippets below into an HTML page, replace pk_live_… with your publishable key, and serve over localhost:
npx serve .
Open two tabs. Each shows a text input + a message log. Typing in one tab broadcasts to the other via the P2P DataChannel (not via the server).
Why this isn't just peer.send
peer.send(data) works for most app-level data, but it has trade-offs:
- Counts against your signalling message quota
For high-frequency or latency-sensitive data (game ticks, telemetry streams, file transfers), a true P2P DataChannel is the better fit. The trade-off is: you're responsible for opening it, handling backpressure, and re-opening after reconnects.
This example shows the minimum complete pattern.
Source walkthrough
The reconnect-aware DataChannel pattern
const channels = new Map(); // peerId → currently-open DataChannel
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
// Discard the previous DC if there was one (closed by reconcile).
channels.get(remote.id)?.close();
// Open a fresh DC on the current pc.
const raw = remote.pc.createDataChannel("messages", {
ordered: false,
maxRetransmits: 0,
});
const dc = new DataChannel(raw, {
maxBufferedAmount: 1_048_576,
maxQueuedSends: 256,
});
dc.on("message", (e) => {
appendMessage(remote.id, JSON.parse(e.data));
});
channels.set(remote.id, dc);
});
});
peer.on("peer-left", ({ peer: remote }) => {
channels.get(remote.id)?.close();
channels.delete(remote.id);
});
The critical detail: DataChannel opening is wired to state-change → "connected", not to peer-joined. This event fires both on the initial peer connection AND on every reconnect cycle, so the DC stays open across drops.
If you skip this pattern and just createDataChannel on peer-joined, your DC silently stops working after the first signalling reconnect.
Coexisting with server-routed messages
Some messages should go via the DataChannel (high-frequency), some via signalling (setup, before DC is open):
// Setup: use peer.send because the DC isn't open yet
peer.on("peer-joined", ({ peer: remote }) => {
peer.sendTo(remote.id, { type: "hello", name: localName });
});
// Steady state: use the DC
async function broadcastTick(tick) {
for (const dc of channels.values()) {
try {
await dc.send(JSON.stringify(tick));
} catch (e) {
if (e instanceof DataChannelOverflowError) {
console.warn("DC overflow; dropping tick for one peer");
}
}
}
}
Backpressure handling
try {
await dc.send(payload);
} catch (e) {
if (e instanceof DataChannelOverflowError) {
// Producer is faster than network can flush.
// Options: throttle, drop, or buffer at a higher layer.
}
}
For a sustained stream where occasional drops are acceptable (game ticks, telemetry), drop is fine. For data that must arrive in order (chunked file transfer), throttle the producer until the wrapper's queue drains.
See also
DataChannelreference — the full wrapper API- Data Channels & Low Latency guide — when to use this pattern + advanced topics
- Reconnect Best Practices — why the
state-changepattern matters