Skip to main content

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 RTCDataChannel via remote.pc.createDataChannel(...) for true P2P data
  • The peer-joined + connection-reset pattern that survives reconnects (SDK 1.2.0+)
  • The DataChannel wrapper for backpressure-aware sends
  • Coexistence with server-routed peer.send for 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:

  1. Exactly one side creates the channel; both sides wire data-channel. Both tabs run the same code, so the remote.polite flag decides the creator; the other side receives the very same channel via the data-channel event — one channel then carries both directions. If both sides created without wiring data-channel, each would send on a channel the other never listens to, and messages would flow nowhere.
  2. The creator opens immediately on peer-joined. Opening the channel is what triggers the P2P connection to negotiate. Don't gate creation on state-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.
  3. The creator re-opens on connection-reset (SDK 1.2.0+). After a signalling reconnect the SDK replaces the underlying RTCPeerConnection; the old channel dies with it. connection-reset fires exactly when the fresh connection is in place — and the receiving side's data-channel listener 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