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/realtime
What it demonstrates
- Opening an
RTCDataChannelviaremote.pc.createDataChannel(...)for true P2P data - The
peer-joined+connection-resetpattern that survives reconnects (SDK 1.2.0+) - 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 → the channel exchanged with that peer
function wireMessageChannel(remote, raw) {
// Discard the previous DC if there was one (closed by the PC swap).
channels.get(remote.id)?.close();
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);
}
function maybeCreateChannel(remote) {
if (remote.polite) return; // exactly one creator per pair
wireMessageChannel(remote, remote.pc.createDataChannel("messages", {
ordered: false,
maxRetransmits: 0,
}));
}
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("data-channel", ({ channel }) => wireMessageChannel(remote, channel)); // receiving side
maybeCreateChannel(remote); // initial connection
remote.on("connection-reset", () => maybeCreateChannel(remote)); // after each reconnect
});
peer.on("peer-left", ({ peer: remote }) => {
channels.get(remote.id)?.close();
channels.delete(remote.id);
});
Three critical details:
- Exactly one side creates the channel; both sides wire
data-channel. Both tabs run the same code, so theremote.politeflag decides the creator; the other side receives the very same channel via thedata-channelevent — one channel then carries both directions. If both sides created without wiringdata-channel, each would send on a channel the other never listens to, and messages would flow nowhere. - The creator opens immediately on
peer-joined. Opening the channel is what triggers the P2P connection to negotiate. Don't gate creation onstate-change→"connected"— in a data-only app like this one, the connection can't reach"connected"until a channel exists, so that gate waits forever. - The creator re-opens on
connection-reset(SDK 1.2.0+). After a signalling reconnect the SDK replaces the underlyingRTCPeerConnection; the old channel dies with it.connection-resetfires exactly when the fresh connection is in place — and the receiving side'sdata-channellistener fires again on its own.
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
connection-resetpattern matters