Data Channels & Low Latency
The default peer.send(data) API is server-routed — your message goes Browser → Metered → Browser. That's the right choice for chat, presence, signalling, and most app-level messages. But for high-frequency, low-latency P2P data — game state ticks, telemetry streams, file transfer — server-routed adds an unnecessary hop and counts against your message quota.
For that, open a real WebRTC RTCDataChannel via the remote.pc.createDataChannel(...) escape hatch.
Decision check — should you use a DataChannel?
| Trait | Server-routed peer.send | P2P RTCDataChannel |
|---|---|---|
| Works before ICE completes | Yes | No |
| Latency | ~10–50 ms (server hop) | ~5–30 ms (direct) |
| Counts against message quota | Yes | No |
| Max payload size | welcome.maxMessageSize (64 KB default) | Browser-dependent (16 KB safe for ordered, ~256 KB unordered) |
| Survives signalling reconnect | Yes | No — must re-open after reconcile |
| Reliable | Yes (server acks) | Configurable: { ordered: true } (default) or { ordered: false } |
| Survives WebRTC PC failure | Yes (signalling and media are separate) | No |
| Complexity | Trivial | You manage backpressure, reconnect, framing |
Use a DataChannel when you have a high enough message rate that the savings on quota + latency outweigh the complexity. Below ~10 msg/sec/peer, peer.send is almost always the right call.
Basic pattern
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
openDataChannel(remote);
});
});
function openDataChannel(remote) {
const dc = remote.pc.createDataChannel("game-state", {
ordered: false, // skip head-of-line blocking
maxRetransmits: 0, // best-effort — drop instead of retransmit
});
dc.onopen = () => console.log("DC open to", remote.id);
dc.onmessage = (ev) => handleTick(remote.id, JSON.parse(ev.data));
dc.onclose = () => console.log("DC closed for", remote.id);
}
await peer.join("game-room");
That's it for the happy path. The complexity comes from two things: reconnect-handling and backpressure.
Reconnect handling — the gotcha
When the signalling WS drops and reconnects, the SDK silently swaps each survivor's underlying RTCPeerConnection for a fresh one. Your RemotePeer reference survives — but the RTCDataChannel you held doesn't. The new PC has no DataChannel until you open one.
If you don't handle this, your DataChannel goes silent after the first WS reconnect and never recovers. Most apps hit this in production the first time someone closes their laptop.
The right pattern keeps a map of open DCs and (re)opens on every state-change → "connected":
const channels = new Map(); // peerId → currently-open RTCDataChannel
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
channels.get(remote.id)?.close(); // discard stale DC if any
const dc = remote.pc.createDataChannel("game-state", {
ordered: false,
maxRetransmits: 0,
});
dc.onmessage = (ev) => handleTick(remote.id, JSON.parse(ev.data));
dc.onopen = () => console.log("DC reopened for", remote.id);
channels.set(remote.id, dc);
});
});
peer.on("peer-left", ({ peer: remote }) => {
channels.get(remote.id)?.close();
channels.delete(remote.id);
});
state-change → "connected" fires:
- Once on the initial connect (when ICE first completes)
- Once per reconcile cycle (after a signalling WS reconnect)
- Once per ICE-restart recovery (if WebRTC alone failed and recovered)
So the pattern handles all three reconnect layers uniformly.
For the full reconnect picture across the SDK, see Reconnect Best Practices.
Backpressure — the other gotcha
RTCDataChannel.send() doesn't return an error if you send faster than the channel can flush — it just buffers internally. If you're streaming continuously (game ticks at 60 Hz, file transfer, telemetry firehose), the buffer can grow unboundedly and crash the tab.
The fix: throttle against dc.bufferedAmount. The SDK ships a DataChannel wrapper that does this for you:
import { DataChannel, DataChannelOverflowError } from "@metered-ca/peer";
const channels = new Map(); // peerId → DataChannel (the wrapper)
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
channels.get(remote.id)?.close();
const raw = remote.pc.createDataChannel("telemetry", { ordered: false });
const dc = new DataChannel(raw, {
maxBufferedAmount: 1_048_576, // pause sends if buffer > 1 MiB (default)
maxQueuedSends: 256, // reject if 256+ sends are already queued
});
dc.on("message", (e) => handle(JSON.parse(e.data)));
channels.set(remote.id, dc);
});
});
// Producer
async function broadcastTick(tick) {
for (const dc of channels.values()) {
try {
await dc.send(JSON.stringify(tick)); // backpressure-aware
} catch (e) {
if (e instanceof DataChannelOverflowError) {
// Producer is faster than network. Drop oldest, throttle upstream,
// or skip this peer for this tick.
console.warn("DC overflow for one peer; dropping");
} else throw e;
}
}
}
For more on the wrapper's options, see DataChannel reference.
Reliability modes — pick yours
createDataChannel(label, options) accepts a config object. The combinations matter:
| Mode | ordered | maxRetransmits / maxPacketLifeTime | Use for |
|---|---|---|---|
| Reliable + ordered (default) | true | (omit) | File transfer, chat-like data |
| Reliable + unordered | false | (omit) | Streams where order doesn't matter, but every byte must arrive (rare) |
| Best-effort + unordered | false | maxRetransmits: 0 | Game ticks, telemetry — drop on loss, no retransmit |
| Time-bounded unordered | false | maxPacketLifeTime: 100 | Live audio-like signals — keep trying for 100 ms then drop |
For most low-latency use cases, ordered: false, maxRetransmits: 0 is the right pick — head-of-line blocking is the enemy of latency, and retransmitting a stale game tick wastes bandwidth.
Framing — the wire is just bytes
DataChannels carry strings or ArrayBuffers. There's no message-type header, no schema validation, no acks (unless you build them). You decide:
// Strings are easiest:
dc.send(JSON.stringify({ tick: 42, players: [...] }));
// Binary for compactness:
const view = new DataView(new ArrayBuffer(16));
view.setUint32(0, tickNumber);
view.setFloat32(4, x);
view.setFloat32(8, y);
view.setFloat32(12, z);
dc.send(view.buffer);
For binary at scale, use a schema (FlatBuffers, MessagePack, custom format) — JSON works but is 3–5x the size of a tight binary encoding.
Connection setup — what to send via signalling vs DataChannel
A common pattern: use peer.send for setup messages that need to arrive before the DataChannel is open, then switch to the DC for steady-state:
peer.on("peer-joined", ({ peer: remote }) => {
// Send setup info immediately — DC isn't open yet, but signalling works.
peer.sendTo(remote.id, { type: "game-init", playerColor: localColor });
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
// DC's open now. Switch to high-frequency ticks.
openTickChannel(remote);
});
});
This pattern works because peer.send is server-routed and works before ICE completes.
Pitfalls
Forgetting to reopen after reconnect. The number-one bug. Wire DC creation to
state-change→"connected"and you're fine. Imperative one-shotcreateDataChannelcalls go stale on every reconnect.No backpressure. Unbounded buffer growth = crashed tab. Use the
DataChannelwrapper or hand-roll backpressure withdc.bufferedAmountchecks.Sending the same message via DC and signalling. Some apps reflexively send everything two ways "for reliability." This doubles your bandwidth and exhausts your signalling quota. Pick one path per message type.
Trusting DC message order with
ordered: false. Best-effort unordered DCs deliver out of order. Always include a sequence number in your payload if order matters.DC messages over the size limit. Browsers crash on DC sends >256 KB (varies). For larger payloads, chunk them at the application layer — say 16 KB chunks with a sequence number.
Memory leaks from un-cleaned-up listeners. If you
dc.onmessage = …withoutdc.onmessage = nullonclose, you can hold references after the DC dies. The SDK'sDataChannelwrapper cleans up onclose()— use it.DC traffic during browser tab throttling. Backgrounded tabs throttle JS heavily; your DataChannel's
onmessagehandlers run rarely, the buffer fills, things drop. Either accept the data loss (game ticks: fine) or pause the producer whendocument.hiddenis true.Treating DCs like a chat channel. DCs are point-to-point only — they don't broadcast. For "send this to everyone,"
peer.send(data)is the right tool (one frame to server, server fans out). DCs scale linearly with peer count.
See also
DataChannelreference — the backpressure wrapperRemotePeerreference — thepcescape hatch- Reconnect Best Practices — required reading
MeteredPeer.send/sendTo— the server-routed alternative