Reconnect Best Practices
Required reading before production. Mobile users will lose Wi-Fi, roam to cellular, background your app, and walk into elevators mid-call. The SDK recovers automatically across three layers — your job is the UI and a couple of edge cases.
The three layers (handled for you)
- Signalling WebSocket — exponential backoff (500 ms → 30 s cap), jitter, close-code-aware. Default 100 attempts.
tokenProvideris re-invoked each attempt so refreshed JWT + TURN creds land automatically. - ICE-restart ladder — per-peer WebRTC recovery: 9 attempts over ~121 s, surfaced as
remote.state == PeerConnectionState.reconnecting. Covers Wi-Fi → cellular roams and TURN failover. - Channel reconcile — on a signalling-WS reconnect, your
RemotePeerreferences are preserved across the drop (sameidentical()object); the underlyingRTCPeerConnectionis silently swapped with fresh TURN creds, and your local streams re-attach.
What survives, what doesn't
Survives: the peer instance, your RemotePeer references, remote.id, remote.metadata, remote.state, stream-level metadata, and local media added via addStream.
Doesn't survive — the four that bite people:
| Reference | Why | Fix |
|---|---|---|
remote.pc cached in a variable | New RTCPeerConnection after reconcile | Re-read remote.pc each time, or re-wire on stateChanges → connected |
MediaStream object bound to a renderer | Fresh object (same stream.id) | Re-assign renderer.srcObject on every onStreamAdded |
DataChannel from remote.pc.createDataChannel | Tied to the old PC | Re-open on stateChanges → connected |
Listeners you added directly on remote.pc | Old PC closed | Listen on the RemotePeer streams (remote.onTrack, …) instead |
The renderer re-bind is the one Flutter-specific gotcha — see the _RemoteView pattern in WebRTC Video Call, which re-binds on every event by construction.
Pattern 1 — the "reconnecting…" banner
Drive it off stateChanges. Delay showing by ~1 s so fast reconnects don't flicker the UI.
Timer? _bannerTimer;
_peer.stateChanges.listen((c) {
if (c.to == MeteredPeerState.reconnecting) {
_bannerTimer = Timer(const Duration(seconds: 1), () {
setState(() => _showReconnecting = true);
});
} else {
_bannerTimer?.cancel();
setState(() => _showReconnecting = c.to != MeteredPeerState.joined ? _showReconnecting : false);
}
});
For a per-peer spinner, use remote.stateChanges the same way (PeerConnectionState.reconnecting).
Pattern 2 — stuck in reconnecting
If the network is genuinely gone, the SDK keeps retrying until maxAttempts. Give the user an out: after ~30 s in reconnecting, surface a "retry now?" action that rebuilds the peer.
Timer? _stuckTimer;
_peer.stateChanges.listen((c) {
_stuckTimer?.cancel();
if (c.to == MeteredPeerState.reconnecting) {
_stuckTimer = Timer(const Duration(seconds: 30), _offerManualRetry);
}
});
Future<void> _offerManualRetry() async {
// close() is terminal — rebuild a fresh peer to rejoin.
await _peer.close();
setState(() => _peer = MeteredPeer(_opts));
await _peer.join(_channel);
}
ReconcileTimeoutErrorThe JavaScript SDK fires an error with err.name === "ReconcileTimeoutError" when the socket is back but the channel roster can't be restored. The Flutter SDK doesn't surface a typed timeout — its reconnect logic supersedes a stale reconcile internally (so it can't wedge on a superseded snapshot), but it has no roster-restore deadline that fires onError. So the 30-second timer above is your safety net — keep it; it covers the "connection never returns" case as well.
Pattern 3 — re-open data channels on reconnect
Data channels die with the old PC. Wire creation to stateChanges → connected, which fires on the initial connect AND once per 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 stale
final raw = await remote.pc.createDataChannel('data');
channels[remote.id] = DataChannel(raw)..onMessage.listen(handle);
});
});
See Data Channels & Low Latency.
Pattern 4 — terminal close codes
Some close codes are not transient — the SDK stops retrying and you must act. MeteredPeer surfaces them on onError (a StateError whose message carries the symbolic code):
peer.onError.listen((err) {
final msg = err.toString();
if (msg.contains('invalid_token') || msg.contains('token_expired')) {
showReloginPrompt();
} else if (msg.contains('account_suspended')) {
showBillingUi();
} else if (msg.contains('admin_disconnect')) {
showKickedUi();
} else if (msg.contains('channel_not_authorized')) {
showAccessDenied();
}
});
If you're on SignallingClient directly, branch precisely on onDisconnected.code against the WsCloseCode constants instead.
Pattern 5 — tokenProvider failures
After repeated tokenProvider() failures the SDK fires onTokenProviderError (informational — it keeps retrying). If the cause is user-actionable (their login expired), prompt them:
client.onTokenProviderError.listen((e) {
if (e.consecutiveFailures >= 3) showReloginPrompt();
});
On MeteredPeer, the same condition past the threshold also lands on onError with token provider failed … in the message.
Test it
Don't ship without exercising the reconnect path. The fastest way:
- Android emulator:
adb -s <emu> shell svc wifi disablethenenablemid-call. - Web: Chrome DevTools → Network → Offline, then back online.
- Real device: toggle airplane mode for a few seconds during a call.
Confirm: the banner appears, video re-binds (the remote tile recovers, not freezes), and a peer that left during the drop is removed when the roster comes back.
See also
- WebRTC Video Call — the
_RemoteViewre-bind pattern MeteredPeerreference → What survives a reconnect- Errors & Codes — every close code + what to do