Reconnecting WebSockets after a network change #

A user walks out of the office, the phone hops from Wi-Fi to cellular, and the live dashboard freezes. No error appears, no reconnect banner shows; the socket still reports OPEN. Forty seconds later — or several minutes, depending on the platform — the connection finally errors out and the reconnect logic kicks in. The same thing happens when a laptop wakes from sleep, when a VPN connects, or when a docking station swaps the Ethernet adapter. In every case the old TCP connection is bound to an IP address that no longer exists, and nothing tells the browser to give up on it quickly. This page shows how to notice the change and replace the socket in under a second.

Root cause #

A TCP connection is identified by its four-tuple: source IP, source port, destination IP, destination port. When the device’s network interface changes, its source IP changes, and every existing connection is orphaned — packets sent on it leave from an address the network no longer routes back to. The connection is not closed by anyone; it is simply unreachable. The browser’s WebSocket will discover this only when a send times out or a server heartbeat stops arriving, and the browser WebSocket API offers no timeout knob for either.

The server sees the mirror image: a connection that has gone silent, which it will eventually clean up through its half-open detection. Neither side is broken; both are waiting for evidence.

Wi-Fi to cellular handoff with a stale socket After the device switches interface, the old socket keeps its OPEN state while sends go nowhere; only a fresh socket over the new network reaches the server. Wi-Fi to cellular handoff with a stale socket App Old socket New network Server connected via Wi-Fi IP interface switches send() — queued no route back, silent readyState still OPEN new socket via cellular The fastest fix is not to rescue the old socket but to notice the change and open a new one
The old four-tuple is dead the instant the interface changes; the browser just has not noticed yet.

The browser does expose weak signals that something changed: the online and offline events on window, the Network Information API’s change event on navigator.connection (Chromium only), and visibilitychange or pageshow when a device wakes. None of them guarantees the socket is dead, but each is a strong hint that it may be, and a cheap application-level probe can confirm it.

Resolution #

The client below listens for every available network-change signal and responds with a probe: it sends an application ping and expects an application pong within a short deadline. If the pong arrives, the socket survived the change and nothing happens. If not, the client abandons the socket and reconnects immediately, skipping the backoff delay — this is not a server outage, so there is no herd to protect against.

const PROBE_TIMEOUT_MS = 1_500;   // a healthy socket answers well inside this
const OFFLINE_GRACE_MS = 250; // let the OS settle before probing after `online`

type Reconnect = (opts: { immediate: boolean; reason: string }) => void;

export function watchNetworkChanges(getSocket: () => WebSocket | null, reconnect: Reconnect) {
let probeTimer: ReturnType<typeof setTimeout> | null = null;

function probe(reason: string) {
const ws = getSocket();
if (!ws || ws.readyState !== WebSocket.OPEN) {
reconnect({ immediate: true, reason }); // nothing to test; just connect
return;
}
if (probeTimer) return; // a probe is already in flight
const nonce = crypto.randomUUID();
const onMessage = (e: MessageEvent) => {
if (typeof e.data === 'string' && e.data.includes(nonce)) {
clearTimeout(probeTimer!);
probeTimer = null;
ws.removeEventListener('message', onMessage);
}
};
ws.addEventListener('message', onMessage);
ws.send(JSON.stringify({ type: 'probe', nonce })); // server echoes { type: 'probe_ack', nonce }
probeTimer = setTimeout(() => {
probeTimer = null;
ws.removeEventListener('message', onMessage);
ws.close(4000, 'network change'); // local close; no need to wait for the peer
reconnect({ immediate: true, reason });
}, PROBE_TIMEOUT_MS);
}

const onOnline = () => setTimeout(() => probe('online'), OFFLINE_GRACE_MS);
const onVisible = () => { if (document.visibilityState === 'visible') probe('visible'); };
const onPageShow = (e: PageTransitionEvent) => { if (e.persisted) probe('bfcache_restore'); };
const conn = (navigator as any).connection as EventTarget | undefined;
const onConnChange = () => probe('connection_change');

window.addEventListener('online', onOnline);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('pageshow', onPageShow);
conn?.addEventListener('change', onConnChange);

return () => {
window.removeEventListener('online', onOnline);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('pageshow', onPageShow);
conn?.removeEventListener('change', onConnChange);
if (probeTimer) clearTimeout(probeTimer);
};
}

On the server, the probe handler is a one-liner that echoes the nonce: if (msg.type === 'probe') ws.send(JSON.stringify({ type: 'probe_ack', nonce: msg.nonce })). It must be answered before any heavy work so that a busy server does not look like a dead network.

Calling close() on the stale socket is still worthwhile even though the close frame will never arrive: it frees the browser-side resources and fires your close handler, which is where your reconnect state machine lives. Pair the immediate reconnect with session resumption so the new socket picks up from the last acknowledged sequence number, as described in resuming WebSocket sessions after reconnect.

Browser signals for a network change Which browser events are available to detect a network change in Chromium, Firefox and Safari. Browser signals for a network change Chrome / Edge Firefox Safari online / offline fires fires fires connection change fires not available not available visibilitychange fires on wake fires on wake fires on wake pageshow persisted bfcache restore bfcache restore bfcache restore online fires on interface changes in most browsers but not on every Wi-Fi to cellular switch, so combine signals
No single event is reliable everywhere; listen to all of them and let the probe decide.

Verification #

Chrome DevTools can simulate the transition. In the Network panel, switch throttling to Offline, wait two seconds, then back to No throttling: the online event fires, the probe runs, and — because DevTools offline mode kills in-flight traffic — the socket is replaced. In the WS messages view you should see the probe frame, no reply, and a fresh connection in under two seconds.

To test a real interface change, run the app on a phone connected to Wi-Fi, open the remote debugger, and turn Wi-Fi off. Log the reason passed to reconnect() and the time between the change and the new socket’s open event:

reconnect = ({ immediate, reason }) => {
const t0 = performance.now();
const ws = openSocket();
ws.addEventListener('open', () =>
console.info(`reconnected (${reason}) in ${Math.round(performance.now() - t0)} ms`), { once: true });
};

A healthy result is a reconnect within one or two seconds of the change instead of a stall lasting until the next server heartbeat timeout.

Recovery with and without a change probe With a probe the app reconnects about 2.4 seconds after the switch; without one it waits roughly 40 seconds for the old socket to error. Recovery with and without a change probe probe path stale wait avoided interface switches (0s) online event (0.3s) probe times out (1.8s) new socket open (2.4s) old socket errors (40s) The long tail varies by platform and heartbeat; the probe path does not
A 1.5 s probe turns a 40 s freeze into a blink.

Operational checklist #

FAQ #

Why doesn’t the WebSocket close when the phone switches from Wi-Fi to 4G? #

The connection is tied to the old IP address, but nobody tells the browser that it is dead: no FIN or RST can reach it over the new network. The socket stays OPEN until a send or receive timeout, which the browser does not expose. You have to detect the change and probe the socket yourself.

Should I skip exponential backoff after a network change? #

Yes, for the first attempt. Backoff exists to protect a struggling server from synchronized retries; a network change on one device is not synchronized with anyone else. Reconnect immediately once, then fall back to your normal jittered backoff if that attempt fails.

Is the offline event enough to close the socket? #

It is a good hint but not proof. offline can fire briefly during a switch, and some platforms fire it only when every interface is down. Mark the connection as suspect and show a subtle banner, but let the probe after online decide whether to replace the socket.

Does QUIC or WebTransport solve this? #

QUIC supports connection migration, so a connection can survive an IP change without a new handshake. That is one of the arguments for WebTransport, but WebSocket runs over TCP, which has no migration, so the replace-and-resume approach is the right one here.

Back to Auto-Reconnection Strategies.