Skip to main content

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)

  1. Signalling WebSocket — exponential backoff (500 ms → 30 s cap), jitter, close-code-aware. Default 100 attempts. tokenProvider is re-invoked each attempt so refreshed JWT + TURN creds land automatically.
  2. 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.
  3. Channel reconcile — on a signalling-WS reconnect, your RemotePeer references are preserved across the drop (same identical() object); the underlying RTCPeerConnection is 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:

ReferenceWhyFix
remote.pc cached in a variableNew RTCPeerConnection after reconcileRe-read remote.pc each time, or re-wire on stateChanges → connected
MediaStream object bound to a rendererFresh object (same stream.id)Re-assign renderer.srcObject on every onStreamAdded
DataChannel from remote.pc.createDataChannelTied to the old PCRe-open on stateChanges → connected
Listeners you added directly on remote.pcOld PC closedListen 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);
}
No ReconcileTimeoutError

The 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 disable then enable mid-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