Skip to main content

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 caseInitWhy
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 signalspeer.send is simpler and works before ICE completes.
  • Anything that must survive WebRTC failure — signalling and media are independent; a peer.send lands 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