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/realtime";
const wrap = (raw) => new DataChannel(raw, {
maxBufferedAmount: 1_048_576, // pause sending if buffer > 1 MiB (default)
maxQueuedSends: 256, // reject if 256+ sends are already queued
});
// Creating side — exactly one peer per pair creates (the impolite one), at
// peer-joined time; opening the channel is what starts the P2P connection,
// so don't wait for a "connected" state.
let dc;
if (!remote.polite) dc = wrap(remote.pc.createDataChannel("file-transfer"));
// Receiving side — the same channel arrives via the data-channel event:
remote.on("data-channel", ({ channel }) => { dc = wrap(channel); });
// …use dc.send below
Options
| Option | Type | Default | Notes |
|---|---|---|---|
maxBufferedAmount | number | 1_048_576 (1 MiB) | When bufferedAmount exceeds this, send() suspends. The wrapper listens for bufferedAmountLow to resume. |
bufferedAmountLowThreshold | number | maxBufferedAmount / 2 | Resume threshold. The wrapper sets dc.bufferedAmountLowThreshold to this value. |
maxQueuedSends | number | 256 | Hard cap on pending sends. If you call send() while this many are queued, it rejects with DataChannelOverflowError. |
logger | Logger | NoopLogger | Optional. |
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:
DataChannelOverflowErrorifmaxQueuedSendsis exceeded — your producer is faster than the network. Either throttle upstream or raisemaxQueuedSends.- Underlying RTCDataChannel error if the channel closes mid-send.
import { DataChannelOverflowError } from "@metered-ca/realtime";
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: the impolite side constructs on peer-joined and re-constructs on each connection-reset event (fired when the SDK replaces the peer's transport after a reconnect — requires @metered-ca/realtime 1.2.0+); the other side wraps the channel it receives via data-channel. One channel carries both directions:
const channels = new Map(); // peerId → DataChannel
function wireFileChannel(remote, raw) {
channels.get(remote.id)?.close();
const dc = new DataChannel(raw, { maxBufferedAmount: 1_048_576 });
dc.on("message", (e) => handle(e.data));
channels.set(remote.id, dc);
}
function maybeCreateChannel(remote) {
if (remote.polite) return; // exactly one creator per pair
wireFileChannel(remote, remote.pc.createDataChannel("file-transfer"));
}
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("data-channel", ({ channel }) => wireFileChannel(remote, channel)); // receiving side
maybeCreateChannel(remote);
remote.on("connection-reset", () => maybeCreateChannel(remote));
});
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 rawRTCDataChannel. 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
RemotePeer—remote.pcis where you callcreateDataChannel- Data Channels & Low Latency — full guide with end-to-end example
- Reconnect Best Practices