Fast WebRTC reconnection without a full ICE restart
When a phone moves from Wi-Fi to cellular, an ICE restart costs a full offer/answer. Against your own SFU there are faster ways to move the call.
A phone drops off Wi-Fi onto cellular in the middle of a call. The standard WebRTC answer is an ICE restart: a new offer and answer over your signalling channel, then a fresh round of connectivity checks before any media moves. Meanwhile the server keeps sending media down the old, dead path.
If you control both the server and the client, you do not have to wait for all of that. Below are three ways to move a call onto a new network path, one standard and two that are not, with what I found testing them against Chrome, mediasoup and pion.
Why server-client WebRTC reconnects differently
WebRTC was designed for peer-to-peer. Both ends gather candidates, send STUN binding requests at each other, wait for responses and agree on a pair. Most production deployments are server-client instead, and that changes the problem.
The server is almost always ICE lite. It gathers nothing, sends no checks of its own, and answers whatever arrives at the one public IP and port it advertised. Your client still runs the full ICE state machine, but it is the only side doing so. That asymmetry decides everything below: the server is the controlled agent, and it only ever reacts to what your client sends it.
Three ways to move to a new path
Once the network changes and the client has a new local candidate, there are three ways to get the server onto it.
1. ICE restart
The correct, spec-sanctioned path. You tell the other side about the new candidate over your signaling channel as part of an actual ICE restart: new ufrag/pwd, a fresh offer/answer, and a new round of connectivity checks. Once a pair succeeds, the controlling agent nominates it the standard way, one STUN binding request carrying USE-CANDIDATE, and the switch happens through the normal ICE state machine.
pc.restartIce();
// this triggers a negotiationneeded event; renegotiate as usual
pc.onnegotiationneeded = async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send(JSON.stringify({ sdp: pc.localDescription }));
};
Works against any RFC 8445-compliant peer. The cost is time: a full offer/answer round trip plus fresh connectivity checks before media moves. iceCandidatePoolSize will not help here either, because the pool is emptied after the first exchange.
2. ICE renomination
A faster path that skips the restart. The mechanism started as draft-thatcher-ice-renomination-01, which expired in 2017 and has since been replaced by draft-thatcher-tsvwg-renomination-00. Neither is an RFC. libwebrtc shipped the original anyway, which is why it works at all.
The idea: an agent advertises a=ice-options:renomination, and from then on the controlling
side may nominate a different pair later in the session. Plain ICE has no procedure for that.
RFC 8445 removed aggressive nomination outright (section 21, “Changes from RFC 5245”), so once a
pair is nominated the only sanctioned way to move is a restart.
The controlling side sends a nomination attribute carrying a counter, alongside the usual
USE-CANDIDATE flag rather than in place of it, and resends until the request is acked. In
libwebrtc the two attributes are added under separate conditions in
ConnectionRequest::Prepare(), so a renomination check carries both. The controlled side is
supposed to keep the highest counter it has seen, which is meant to protect against packets
arriving out of order.
Watch out for the attribute value. libwebrtc uses 0xC001, which appears in no draft; the
current draft assigns 0x0030. mediasoup accepts both and calls the libwebrtc one
NOMINATION_OLD.
Renomination is not available in a browser
enableIceRenomination is not a member of Blink’s RTCConfiguration dictionary. Passing it is
silently ignored, because unknown dictionary members always are. Run Chrome against a peer that
logs incoming STUN attributes and the two configurations are indistinguishable:
# with enableIceRenomination: true, and without it
a=ice-options:trickle <- identical offers, no renomination advertised
USERNAME 0xC057 ICE-CONTROLLING USE-CANDIDATE PRIORITY MESSAGE-INTEGRITY FINGERPRINT
Not one 0xC001 in any check, either way. The flag exists in native libwebrtc
(enable_ice_renomination, default false in peer_connection_interface.h), so renomination is
available to native clients and to anything embedding libwebrtc directly. From a web page it is
not reachable. If your client is a browser, skip to the next section.
3. Latching on with USE-CANDIDATE
No draft covers this one, but it is the option that actually works from a browser, and on some servers it is a deliberate feature rather than an accident.
You send an authenticated binding request with USE-CANDIDATE from the new tuple, and the server
switches to it. No renegotiation, no ice-options, no nomination counter. Whether that works is
entirely up to the server’s ICE implementation, and the two I tested sit at opposite ends.
mediasoup switches, by design. It is ICE-lite, it generates no SDP at all, and it advertises
no ice-options. IceServer.cpp moves the selected tuple on any authenticated request carrying
USE-CANDIDATE:
const auto isNewNomination = hasNomination && nomination > this->remoteNomination;
if (isNewNomination || hasUseCandidate)
{
SetSelectedTuple(storedTuple);
Sending one crafted check from a fresh source port is enough to move it, even from something that is not a WebRTC peer at all and negotiated nothing.
pion does not switch. In full ICE and in ICE-lite mode it answers the check with a success response and keeps the pair it already had. Authentication is not the obstacle; it simply does not treat an unsolicited nomination as a reason to move.
That || hasUseCandidate is also worth reading twice if you were counting on the nomination
counter for ordering. Against mediasoup the counter only decides anything when USE-CANDIDATE is
absent:
| check from a new tuple | mediasoup |
|---|---|
USE-CANDIDATE + nomination 100 |
switches |
nomination 50, no USE-CANDIDATE (lower) |
ignored |
nomination 900, no USE-CANDIDATE (higher) |
switches |
USE-CANDIDATE, no nomination, after 900 |
switches |
The last row is the one that bites. The draft says a request carrying a value takes precedence
over one carrying none, but USE-CANDIDATE short-circuits the comparison. Since libwebrtc always
sends USE-CANDIDATE next to the counter, the out-of-order protection never engages on that
pairing at all. A stale retransmission can take the tuple back.
Verify against the server you actually deploy against, and pin the version. This is behaviour in
one if, and nothing stops it changing between releases.
When should the server trust a new path?
Whichever path you use, once the server sees a binding request from a new tuple, it knows the request is authenticated, since STUN requests are signed with the session’s short-term credentials, but authentication alone doesn’t tell it whether you want to commit to that path yet. A few ways to handle that:
Only ever send a request when you mean it. Treat a successful nomination or renomination on a new tuple as a hard signal. Simplest option, but you need to be sure before you send.
Confirm out-of-band. Send the request, but don’t rely on it alone. Follow up over your signaling channel and have the server explicitly approve the switch before treating it as final.
Skip the browser entirely. If your client is a custom ICE stack rather than RTCPeerConnection, you control exactly when the request goes out and what confirmation logic wraps around it.
Which one to use
If your client is a browser and your server is mediasoup, latching is the fastest option you have. One authenticated STUN request from the new network and the media follows, with no signalling round trip at all.
If the server is pion, or anything you have not tested, do the ICE restart and make it start sooner. Trigger it as soon as the page sees the network change instead of waiting for iceConnectionState to reach failed, and keep the signalling connection alive so the offer does not queue behind a reconnect of its own.
Renomination only makes sense for native clients built on libwebrtc, and only against a server that understands the attribute value libwebrtc sends.