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). Both sides run the same code, so exactly one side creates the channel — the impolite one (where remote.polite is false) — as soon as onPeerJoined delivers the peer; creating it is what starts the P2P connection (don't wait for connected: a data-only app never reaches it until a channel exists). The other side receives the very same channel on remote.onDataChannel, so one channel carries both directions. After a reconnect, the creating side re-opens when the peer returns to joined:
final channels = <String, DataChannel>{};
Future<void> wireGameChannel(RemotePeer remote, RtcDataChannelLike raw) async {
await channels.remove(remote.id)?.close(); // discard a stale one
final dc = DataChannel(raw);
dc.onMessage.listen((m) {
final text = m.text;
if (text == null) return;
applyTick(remote.id, jsonDecode(text));
});
channels[remote.id] = dc;
}
Future<void> maybeCreateChannel(RemotePeer remote) async {
if (remote.polite) return; // exactly one creator per pair
final raw = await remote.pc.createDataChannel('game-state',
{'ordered': false, 'maxRetransmits': 0}); // unreliable, for game ticks
await wireGameChannel(remote, raw);
}
peer.onPeerJoined.listen((remote) {
// Receiving side — the creator's channel arrives here (also again after
// every reconnect, on the fresh transport).
remote.onDataChannel.listen((raw) => wireGameChannel(remote, raw));
maybeCreateChannel(remote);
});
// After a drop, the SDK refreshes every survivor's transport while the peer
// is `reconnecting`; when it returns to `joined`, the creator re-opens.
peer.stateChanges.listen((c) {
if (c.from == MeteredPeerState.reconnecting &&
c.to == MeteredPeerState.joined) {
peer.remotePeers.forEach(maybeCreateChannel);
}
});
peer.onPeerLeft.listen((remote) async {
await channels.remove(remote.id)?.close();
});
Either side sends with channels[remote.id]?.send(...) — the creator on the channel it opened, the receiver on the one that arrived via onDataChannel.
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: the impolite side opens on onPeerJoined and re-opens when peer.stateChanges transitions reconnecting → joined (the SDK refreshes every survivor's transport before that transition fires); both sides wire onDataChannel; and the previous channel is close()d before the new one replaces it. Two anti-patterns to avoid: gating creation on the remote's stateChanges → connected (in a data-only app the connection reaches connected only because a channel was created, so that gate waits forever), and both sides creating without wiring onDataChannel (each side sends on a channel the other never listens to — messages flow nowhere).
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