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/realtime";
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
peer.on("peer-joined", ({ peer: remote }) => {
// Both sides run this same code, so exactly ONE side creates the channel
// (the impolite one) and the other receives it via "data-channel" — one
// channel then carries both directions.
remote.on("data-channel", ({ channel }) => wireChannel(remote, channel));
maybeCreateChannel(remote);
// After a network drop the SDK replaces the transport and the old channel
// dies with it. The creating side opens a fresh one; the receiving side's
// "data-channel" listener fires again automatically.
remote.on("connection-reset", () => maybeCreateChannel(remote));
});
function maybeCreateChannel(remote) {
// Creating the channel is what starts the P2P connection — don't wait for
// "connected": a data-only app never reaches it until a channel exists.
if (remote.polite) return;
wireChannel(remote, remote.pc.createDataChannel("game-state", {
ordered: false, // skip head-of-line blocking
maxRetransmits: 0, // best-effort — drop instead of retransmit
}));
}
function wireChannel(remote, dc) {
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");
The connection-reset event requires @metered-ca/realtime 1.2.0+.
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, creates on peer-joined (impolite side only — the other side receives), and re-creates on every connection-reset:
const channels = new Map(); // peerId → the channel exchanged with that peer
function wireGameChannel(remote, dc) {
channels.get(remote.id)?.close(); // discard stale DC if any
dc.onmessage = (ev) => handleTick(remote.id, JSON.parse(ev.data));
dc.onopen = () => console.log("DC open for", remote.id);
channels.set(remote.id, dc);
}
function maybeCreateChannel(remote) {
if (remote.polite) return; // exactly one creator per pair
wireGameChannel(remote, remote.pc.createDataChannel("game-state", {
ordered: false,
maxRetransmits: 0,
}));
}
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("data-channel", ({ channel }) => wireGameChannel(remote, channel)); // receiving side
maybeCreateChannel(remote); // initial transport
remote.on("connection-reset", () => maybeCreateChannel(remote)); // each replacement
});
peer.on("peer-left", ({ peer: remote }) => {
channels.get(remote.id)?.close();
channels.delete(remote.id);
});
Either side sends with channels.get(remote.id)?.send(...) — the creator on the channel it opened, the receiver on the one that arrived via data-channel.
connection-reset fires once per transport replacement — each time the signalling WS reconnects and the SDK swaps a survivor's RTCPeerConnection. That's exactly the set of moments your previous channel died. ICE-restart recoveries repair the same connection in place, so your channel survives those and no re-open is needed. Together with peer-joined covering the initial transport, every case is handled.
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/realtime";
const channels = new Map(); // peerId → DataChannel (the wrapper)
function wireTelemetry(remote, raw) {
channels.get(remote.id)?.close();
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);
}
function maybeCreateChannel(remote) {
if (remote.polite) return; // one creator per pair; the other side receives
wireTelemetry(remote, remote.pc.createDataChannel("telemetry", { ordered: false }));
}
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("data-channel", ({ channel }) => wireTelemetry(remote, channel));
maybeCreateChannel(remote);
remote.on("connection-reset", () => maybeCreateChannel(remote));
});
// 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 — signalling works before the DC opens.
peer.sendTo(remote.id, { type: "game-init", playerColor: localColor });
// Tick channel: the impolite side creates it, the other side receives it via
// "data-channel"; each side's dc.onopen marks its switchover from
// server-routed setup messages to high-frequency P2P ticks.
remote.on("data-channel", ({ channel }) => wireTickChannel(remote, channel));
if (!remote.polite) wireTickChannel(remote, remote.pc.createDataChannel("ticks", { ordered: false }));
remote.on("connection-reset", () => {
if (!remote.polite) wireTickChannel(remote, remote.pc.createDataChannel("ticks", { ordered: false }));
});
});
This pattern works because peer.send is server-routed and works before ICE completes.
Pitfalls
Forgetting to reopen after reconnect. The number-one bug. Have the impolite side create on
peer-joinedand re-create onconnection-reset, with both sides wiringdata-channel, and you're fine. Don't gate creation onstate-change→"connected"— in a data-only app the connection only reaches"connected"because a channel was created, so that pattern waits forever. And if both sides create while neither wiresdata-channel, each side sends on a channel the other never listens to — messages flow nowhere.remote.politeis the ready-made tie-break for who creates.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