Skip to content
WebRTC.wiki

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.

6 min readupdated

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.