WebSocket Reconnection With Exponential Backoff and State Recovery in React

By James Nguyen Updated September 24, 2026
WebSocket Reconnection With Exponential Backoff and State Recovery in React

The first version of a live-updating dashboard I built just opened a WebSocket in a useEffect and called it done, which worked perfectly in development and fell apart the first time a user's laptop went to sleep or their wifi blipped for ten seconds. The socket died silently, the UI kept rendering stale data as if nothing had happened, and nobody noticed until a support ticket came in asking why the numbers hadn't moved in an hour. Getting reconnection actually right took more code than the happy path, but almost none of it is optional once real networks are involved.

Detecting the Disconnect Isn't as Obvious as It Sounds

The WebSocket API's close event fires reliably when the server closes the connection or the client calls .close(), but a dead network doesn't always trigger it promptly, sometimes the browser takes a while to notice the underlying TCP connection is gone. Pairing the close handler with a heartbeat, a ping sent on an interval that expects a pong back within a timeout, catches the silent-death case a bare close listener misses. If no pong arrives within the timeout window, I treat the connection as dead and tear it down manually rather than waiting for the browser to eventually notice.

Exponential Backoff, Not a Fixed Retry Interval

Retrying every second forever sounds responsive but turns a server restart affecting thousands of connected clients into a thundering herd hammering the server with reconnect attempts the moment it comes back up. Exponential backoff with jitter, doubling the delay after each failed attempt up to a cap, with a random offset added so clients don't all retry in lockstep, spreads that load out. Capping the backoff at something reasonable, thirty seconds in my case, keeps a long outage from making genuine reconnection feel like it's given up.

function getBackoffDelay(attempt, baseMs = 1000, maxMs = 30000) {
  const exponential = Math.min(maxMs, baseMs * 2 ** attempt);
  const jitter = Math.random() * exponential * 0.3;
  return exponential + jitter;
}

The Hook: Managing Connection Lifecycle Declaratively

Wrapping all of this in a custom hook keeps components that just want "the current connection state and a send function" from needing to know about backoff timers or heartbeat intervals at all. The hook owns the WebSocket instance in a ref, not state, since replacing the socket object shouldn't itself trigger a re-render, only the derived connection status should.

function useWebSocket(url) {
  const [status, setStatus] = useState('connecting');
  const wsRef = useRef(null);
  const attemptRef = useRef(0);
  const heartbeatRef = useRef(null);

  useEffect(() => {
    let cancelled = false;

    function connect() {
      const ws = new WebSocket(url);
      wsRef.current = ws;

      ws.onopen = () => {
        attemptRef.current = 0;
        setStatus('connected');
        heartbeatRef.current = setInterval(() => {
          ws.send(JSON.stringify({ type: 'ping' }));
        }, 15000);
      };

      ws.onclose = () => {
        clearInterval(heartbeatRef.current);
        if (cancelled) return;
        setStatus('reconnecting');
        const delay = getBackoffDelay(attemptRef.current++);
        setTimeout(connect, delay);
      };

      ws.onerror = () => ws.close();
    }

    connect();
    return () => {
      cancelled = true;
      clearInterval(heartbeatRef.current);
      wsRef.current?.close();
    };
  }, [url]);

  const send = useCallback((data) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  return { status, send };
}

State Recovery: The Part Reconnection Tutorials Usually Skip

Reconnecting the socket itself is only half the problem. Every message the server would have pushed during the gap is gone, the client has no idea what it missed. The fix I landed on: the server assigns each pushed message a monotonically increasing sequence number per channel, the client tracks the last sequence number it successfully processed, and on reconnect the client sends that number back so the server can replay anything missed instead of the client just resuming from whatever arrives next.

Handling the Case Where Replay Isn't Possible

A server can't replay messages it didn't retain, if the gap was long enough that the replay buffer expired, or the client was disconnected across a server restart that dropped the buffer entirely. For that case, the client needs an explicit "resync" signal from the server meaning "too much was missed, re-fetch full state," which falls back to a plain REST call for the current snapshot rather than pretending an incremental replay happened when it didn't. Silently trusting an incomplete replay is worse than an honest full refetch.

Surfacing Connection State to the User Honestly

The dashboard originally showed no indication of connection status at all, which is how the stale-data ticket happened in the first place. A small, unobtrusive indicator, a colored dot or a "reconnecting…" label tied directly to the hook's status value, gives users an honest signal that the data might be stale during a reconnect window, rather than letting them trust numbers that quietly stopped updating.

Testing Reconnection Without Actually Killing Your Wifi

Manually unplugging the network to test this got old fast. A small local proxy that sits between the client and the real WebSocket server, and that I can command to drop the connection on demand via a test-only HTTP endpoint, let me script reconnection scenarios, drop mid-message, drop during the heartbeat interval, drop repeatedly to exercise the backoff ceiling, as part of an actual test suite instead of manual verification before every deploy.

Final Verdict

None of this is exotic, heartbeats, exponential backoff with jitter, and sequence-based replay are all well-known patterns individually, but stitching them together correctly, and specifically handling the case where replay isn't possible, is what separates a WebSocket connection that recovers gracefully from one that just quietly shows stale data after the first network hiccup. The naive useEffect version works in a demo; this is what it takes to survive a real user's actual wifi.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles