DataChannel
A thin backpressure-aware wrapper around a raw WebRTC data channel. Use it when you've opened a channel via the remote.pc escape hatch and need to send more bytes per second than the transport buffer can sustain — continuous telemetry, file transfer, 60 Hz game state.
You don't need it for occasional small messages — call send(...) on the raw RtcDataChannelLike directly. You need it when you could outpace the channel's send queue.
Why backpressure matters
WebRTC doesn't return an error when you send faster than the channel can flush — it silently buffers. Let the buffer grow unboundedly and memory climbs until the app is killed. The fix is to throttle sends against the transport's bufferedAmount, which is exactly what this wrapper does.
Construct
import 'package:metered_realtime/metered_realtime.dart';
remote.stateChanges.listen((c) async {
if (c.to != PeerConnectionState.connected) return;
final raw = await remote.pc.createDataChannel('file-transfer');
final dc = DataChannel(raw, const DataChannelOptions(
maxBufferedAmount: 1048576, // pause sending if buffer > 1 MiB (default)
maxQueuedSends: 256, // reject if 256+ sends are already queued
));
// …use dc.send below
});
The wrapper takes over the channel's event callbacks — don't also set them on the raw channel after wrapping it.
DataChannelOptions
| Option | Type | Default | Notes |
|---|---|---|---|
maxBufferedAmount | int | 1048576 (1 MiB) | When bufferedAmount exceeds this, send() suspends until the buffer drains. Must be > 0 (else ArgumentError). |
bufferedAmountLowThreshold | int? | maxBufferedAmount ~/ 2 | Resume threshold; the wrapper arms it on the channel. Must be in 1..maxBufferedAmount if set. |
maxQueuedSends | int | 256 | Hard cap on pending sends. The (maxQueuedSends + 1)th send() rejects with DataChannelOverflowError. Must be >= 1. |
logger | Logger? | NoopLogger | Optional. |
Methods
send(message) → Future<void>
Queues a send. Suspends if bufferedAmount + payload > maxBufferedAmount; resumes when the buffer drains. Concurrent calls are chain-sequenced so a burst can't re-trip the ceiling the instant it drains.
for (final chunk in fileChunks) {
await dc.send(RtcDataChannelMessage.binary(chunk)); // backpressure-aware
}
RtcDataChannelMessage carries text or binary:
RtcDataChannelMessage.text('hello'); // .isBinary == false
RtcDataChannelMessage.binary(Uint8List.fromList([1, 2, 3]));
dc.sendText('hello') is shorthand for send(RtcDataChannelMessage.text('hello')).
Rejects with:
DataChannelOverflowErrorifmaxQueuedSendsis exceeded — your producer is faster than the network. Throttle upstream or raise the cap.- A
StateErrorif the channel isn'topen(closed mid-send, or never opened).
try {
await dc.send(RtcDataChannelMessage.binary(chunk));
} on DataChannelOverflowError catch (e) {
// e.queued = current pending count; e.cap = your maxQueuedSends
pauseProducer();
}
close() → Future<void>
Closes the underlying channel and detaches listeners. Idempotent. Any backpressure-suspended send() rejects rather than hanging.
Read-only state
dc.label // String? — the label passed to createDataChannel
dc.readyState // String? — "connecting" | "open" | "closing" | "closed"
dc.bufferedAmount // int? — bytes pending in the transport buffer
Streams
dc.onOpen.listen((_) => …); // Stream<void>
dc.onClose.listen((_) => …); // Stream<void> — transport-side close only
dc.onMessage.listen((m) => handle(m.text)); // Stream<RtcDataChannelMessage>
dc.onError.listen((e) => …); // Stream<Object>
onClose fires when the channel closes from the transport side (remote closed it, or the connection went away) — not for your own close() call.
onError on the flutter_webrtc bindingThe flutter_webrtc binding doesn't surface transport-level data-channel errors, so on this SDK onError never emits — all failure reporting comes through rejected send() futures and the onClose event. The stream exists for API symmetry and for future bindings. Don't rely on onError to detect a broken channel; rely on send() rejections + onClose.
What survives a reconnect
Nothing. A DataChannel wraps a transport channel tied to the RTCPeerConnection it was opened on. When the signalling WS reconnects, the SDK swaps each survivor's PC, closing every channel on the old one.
Re-construct on each stateChanges → connected (fires on initial connect AND each reconcile):
final channels = <String, DataChannel>{};
peer.onPeerJoined.listen((remote) {
remote.stateChanges.listen((c) async {
if (c.to != PeerConnectionState.connected) return;
await channels.remove(remote.id)?.close();
final raw = await remote.pc.createDataChannel('file-transfer');
final dc = DataChannel(raw, const DataChannelOptions(maxBufferedAmount: 1048576));
dc.onMessage.listen((m) => handle(m));
channels[remote.id] = dc;
});
});
peer.onPeerLeft.listen((remote) async {
await channels.remove(remote.id)?.close();
});
See Data Channels & Low Latency for the full reconnect-aware producer pause/resume.
When NOT to use this wrapper
- Occasional messages (chat, low-frequency events) — call
send(...)on the rawRtcDataChannelLike. No need for queueing. - Server-routed messages —
peer.send(data)already has its own queueing + ack at the wire layer. This wrapper is for the P2P escape hatch. - Reliable file transfer with chunk-level retry — this handles backpressure, not retry-on-failure. If the channel closes mid-transfer, add application-level resume logic.
See also
RemotePeer—remote.pcis where you callcreateDataChannel- Data Channels & Low Latency
- Reconnect Best Practices