Data Channels & Low Latency
peer.send / peer.sendTo are server-routed — Device → Metered → Device. That's the right default (works before ICE completes, survives WebRTC failure, same code for broadcast + direct). But for high-rate peer-to-peer data — game state at 60 Hz, telemetry streams, file transfer — you want a real P2P data channel: lower latency, and it doesn't count against your signalling quota.
The SDK doesn't put a data channel on its main surface, but flutter_webrtc's is one method away via the remote.pc escape hatch, and DataChannel wraps it with backpressure handling.
Open a channel
createDataChannel is async in Dart (unlike the browser's synchronous call). Wire it to stateChanges → connected so it opens once the peer connection is ready — and re-opens on every 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(); // discard a stale one
final raw = await remote.pc.createDataChannel('game-state',
{'ordered': false, 'maxRetransmits': 0}); // unreliable, for game ticks
final dc = DataChannel(raw);
dc.onMessage.listen((m) => applyTick(remote.id, jsonDecode(m.text)));
channels[remote.id] = dc;
});
});
peer.onPeerLeft.listen((remote) async {
await channels.remove(remote.id)?.close();
});
The receiving side gets the channel on remote.onDataChannel (an RtcDataChannelLike) — wrap it the same way:
remote.onDataChannel.listen((raw) {
final dc = DataChannel(raw);
dc.onMessage.listen((m) => handle(m));
});
The reconnect gotcha (it bites everyone once)
A data channel is 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. If you opened the channel once on the first connected and cached it, after a reconnect you're holding a dead channel and your sends silently fail.
The fix is the pattern above: wire creation to stateChanges → connected (fires on initial connect AND each reconcile), and close() the previous channel before opening the new one. Don't open data channels anywhere else.
Backpressure — why DataChannel exists
WebRTC doesn't error when you send faster than the transport can flush; it buffers, and an unbounded buffer climbs memory until the app is killed. DataChannel.send suspends while the transport buffer is above maxBufferedAmount and resumes when it drains — so a for loop over chunks self-throttles:
final dc = DataChannel(raw, const DataChannelOptions(
maxBufferedAmount: 1048576, // 1 MiB
maxQueuedSends: 256, // reject past this many pending
));
for (final chunk in fileChunks) {
try {
await dc.send(RtcDataChannelMessage.binary(chunk)); // backpressure-aware
} on DataChannelOverflowError {
// Producer outran the network past the queue cap — pause and resume later.
await pauseUntilDrained();
}
}
Past maxQueuedSends pending sends, send rejects with DataChannelOverflowError — a real backpressure signal you can act on, instead of unbounded memory growth. On the flutter_webrtc native binding the buffered-amount reading is event-cached, so the wrapper confirms with a fresh platform read before suspending and uses a short backstop poll — a missed drain event degrades to polling, never a hang.
Ordered vs unreliable
createDataChannel's init map mirrors the WebRTC RTCDataChannelInit:
| Use case | Init | Why |
|---|---|---|
| File transfer, reliable state sync | {} (default: ordered + reliable) | Every byte arrives, in order |
| Game ticks, cursor positions, live telemetry | {'ordered': false, 'maxRetransmits': 0} | Drop stale packets rather than head-of-line block |
For unreliable channels, a dropped tick is fine — the next one supersedes it. Don't pay for retransmission you'll throw away.
When to stay server-routed
- Chat, presence, control signals —
peer.sendis simpler and works before ICE completes. - Anything that must survive WebRTC failure — signalling and media are independent; a
peer.sendlands even if the P2P connection never negotiates. - Occasional small messages — not worth a channel.
Reach for a P2P data channel only when the message rate or latency genuinely needs it.
See also
DataChannelreference — options, methods, theonErrorcaveatRemotePeer→ thepcescape hatch- Reconnect Best Practices — Pattern 3 is the reopen flow