Skip to main content

DataChannel

A thin backpressure-aware wrapper around an RTCDataChannel. Use this when you've opened a DC via the remote.pc.createDataChannel(...) escape hatch and need to send more bytes per second than the buffer can sustain.

You don't need this for occasional small messages — call dc.send(...) on the raw RTCDataChannel directly. You need it when you're streaming continuously (telemetry, file transfer, game state at 60 Hz) and could outpace the channel's send queue.

Why backpressure matters

Browsers don't return an error when you dc.send(...) faster than the channel can flush. They silently buffer. If the buffer grows unboundedly, the page memory keeps climbing and eventually the tab crashes. The fix is to throttle sends against dc.bufferedAmount, which is exactly what this wrapper does.

Construct

import { DataChannel } from "@metered-ca/peer";

remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
const raw = remote.pc.createDataChannel("file-transfer");
const dc = new DataChannel(raw, {
maxBufferedAmount: 1_048_576, // pause sending if buffer > 1 MiB (default)
maxQueuedSends: 256, // reject if 256+ sends are already queued
});
// …use dc.send below
});

Options

OptionTypeDefaultNotes
maxBufferedAmountnumber1_048_576 (1 MiB)When bufferedAmount exceeds this, send() suspends. The wrapper listens for bufferedAmountLow to resume.
bufferedAmountLowThresholdnumbermaxBufferedAmount / 2Resume threshold. The wrapper sets dc.bufferedAmountLowThreshold to this value.
maxQueuedSendsnumber256Hard cap on pending sends. If you call send() while this many are queued, it rejects with DataChannelOverflowError.
loggerLoggerNoopLoggerOptional.

Methods

send(data)Promise<void>

Queues a send. Suspends if bufferedAmount > maxBufferedAmount; resumes when the buffer drains below bufferedAmountLowThreshold. Chain-sequenced — concurrent calls won't burst the buffer.

for await (const chunk of fileChunks) {
await dc.send(chunk); // backpressure-aware
}

data is string | ArrayBuffer. For typed arrays, use chunk.buffer.

Rejects with:

  • DataChannelOverflowError if maxQueuedSends is exceeded — your producer is faster than the network. Either throttle upstream or raise maxQueuedSends.
  • Underlying RTCDataChannel error if the channel closes mid-send.
import { DataChannelOverflowError } from "@metered-ca/peer";

try {
await dc.send(chunk);
} catch (e) {
if (e instanceof DataChannelOverflowError) {
// queued = current pending count; cap = your maxQueuedSends
pauseProducer();
} else throw e;
}

close()void

Closes the underlying RTCDataChannel and detaches listeners. Idempotent. Use this when you're done with the channel; reading peers will fire their close event.

Read-only state

dc.label             // string — the label you passed to createDataChannel
dc.readyState // "connecting" | "open" | "closing" | "closed"
dc.bufferedAmount // current bytes pending in the underlying buffer

Events

dc.on("open", () =>);
dc.on("close", () =>);
dc.on("message", (ev) => handle(ev.data));
dc.on("error", (ev) =>);

Same events the underlying RTCDataChannel emits — the wrapper passes them through.

What survives a reconnect

Nothing. The DataChannel wraps an RTCDataChannel, which is tied to the RTCPeerConnection it was opened on. When the signalling WS reconnects, the SDK swaps each survivor's PC, which closes every DataChannel on the old PC.

The right pattern: re-construct the wrapper on each state-change"connected" event:

const channels = new Map(); // peerId → DataChannel

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("file-transfer");
const dc = new DataChannel(raw, { maxBufferedAmount: 1_048_576 });
dc.on("message", (e) => handle(e.data));
channels.set(remote.id, dc);
});
});

peer.on("peer-left", ({ peer: remote }) => {
channels.get(remote.id)?.close();
channels.delete(remote.id);
});

See Data Channels & Low Latency for the full pattern including reconnect-aware producer pause/resume.

When NOT to use this wrapper

  • Occasional messages (chat, low-frequency events). Just dc.send(...) on the raw RTCDataChannel. No need for queueing.
  • Server-routed messages. peer.send(data) already has its own queueing + ack flow at the wire layer. The DataChannel wrapper is specifically for P2P sends.
  • Reliable file transfer with chunk-level retry. This wrapper handles backpressure, not retry-on-failure. If the channel closes mid-transfer, you'll need application-level resume logic.

See also