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/peer

What it demonstrates

  • Opening an RTCDataChannel via remote.pc.createDataChannel(...) for true P2P data
  • The state-change"connected" pattern that survives reconnects
  • 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 → 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