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.
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.
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.
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.
Related #
- Handling WebSocket Disconnects Gracefully — the reconnect state machine these signals feed.
- WebSocket Reconnect with the Page Visibility API — pausing and resuming around background tabs.
- Queueing WebSocket Messages While Offline — keeping user actions safe across the gap.
- Resuming WebSocket Sessions After Reconnect — replaying what was missed.
Back to Auto-Reconnection Strategies.