Networks fail. Servers restart. Mobile users switch between WiFi and LTE. A production WebSocket app must handle reconnection automatically. The key strategy is exponential backoff — waiting progressively longer between retries to avoid overwhelming the server.


Why Not Retry Immediately?

If your server restarts and 10,000 clients all reconnect at the exact same millisecond, the server dies before it can serve anyone. This is called the thundering herd problem.

Solution: Exponential backoff + jitter

delay = min( baseDelay × 2^attempt, maxDelay ) + random(0, 1000)ms

Retry schedule:
  Attempt 1 →  1s  + jitter
  Attempt 2 →  2s  + jitter
  Attempt 3 →  4s  + jitter
  Attempt 4 →  8s  + jitter
  Attempt 5 → 16s  + jitter
  Attempt 6+ → 30s + jitter  (capped at maxDelay)

Production ReconnectingWebSocket Class

// reconnecting-ws.js
class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url         = url;
    this.maxRetries  = options.maxRetries  ?? Infinity;
    this.baseDelay   = options.baseDelay   ?? 1000;    // 1s
    this.maxDelay    = options.maxDelay    ?? 30000;   // 30s
    this.jitter      = options.jitter      ?? true;
    this.attempt     = 0;
    this.shouldClose = false; // prevent reconnect after manual close

    // Callbacks — override these
    this.onopen    = () => {};
    this.onmessage = () => {};
    this.onerror   = () => {};
    this.onclose   = () => {};

    this.connect();
  }

  connect() {
    if (this.shouldClose) return;

    console.log(`Connecting to ${this.url}... (attempt ${this.attempt + 1})`);
    this.ws = new WebSocket(this.url);

    this.ws.onopen = (e) => {
      console.log('✅ WebSocket connected!');
      this.attempt = 0; // reset counter on success
      this.onopen(e);
    };

    this.ws.onmessage = (e) => this.onmessage(e);
    this.ws.onerror   = (e) => this.onerror(e);

    this.ws.onclose = (e) => {
      this.onclose(e);

      if (!this.shouldClose && this.attempt < this.maxRetries) {
        // Exponential backoff
        let delay = Math.min(
          this.baseDelay * Math.pow(2, this.attempt),
          this.maxDelay
        );

        // Add jitter to spread out reconnection attempts
        if (this.jitter) delay += Math.random() * 1000;

        console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt + 1})`);
        setTimeout(() => this.connect(), delay);
        this.attempt++;
      } else if (this.attempt >= this.maxRetries) {
        console.error('Max retries reached. Giving up.');
      }
    };
  }

  send(data) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(data);
      return true;
    }
    console.warn('Cannot send — WebSocket not open');
    return false;
  }

  close() {
    this.shouldClose = true; // prevent auto-reconnect
    this.ws?.close(1000, 'Normal closure');
  }

  get readyState() {
    return this.ws?.readyState ?? WebSocket.CLOSED;
  }
}

Usage

// main.js
const ws = new ReconnectingWebSocket('wss://api.example.com/ws', {
  maxRetries: 10,
  baseDelay:  1000,
  maxDelay:   30000,
  jitter:     true,
});

ws.onopen = () => {
  console.log('Connected!');
  ws.send(JSON.stringify({ type: 'hello' }));
};

ws.onmessage = (e) => {
  console.log(JSON.parse(e.data));
};

ws.onclose = (e) => {
  console.log('Disconnected:', e.code, e.reason);
};

// Manually close (won't reconnect)
ws.close();

React Hook — useReconnectingWebSocket

// useWebSocket.js
import { useEffect, useRef, useCallback } from 'react';

export function useWebSocket(url) {
  const wsRef      = useRef(null);
  const attemptRef = useRef(0);

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

    ws.onopen = () => { attemptRef.current = 0; };

    ws.onclose = () => {
      const delay = Math.min(1000 * 2 ** attemptRef.current, 30000);
      attemptRef.current++;
      setTimeout(connect, delay);
    };

    return ws;
  }, [url]);

  useEffect(() => {
    const ws = connect();
    return () => {
      ws.onclose = null; // prevent reconnect on unmount
      ws.close();
    };
  }, [connect]);

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

  return { send, ws: wsRef };
}

Server-Side: Restart Gracefully

// server.js — graceful shutdown signals
process.on('SIGTERM', () => {
  console.log('Server shutting down gracefully...');

  // Notify all clients so they can reconnect
  wss.clients.forEach((ws) => {
    ws.send(JSON.stringify({
      type:    'server_shutdown',
      message: 'Server restarting. Please reconnect in a few seconds.',
    }));
    ws.close(1001, 'Server going down');
  });

  wss.close(() => process.exit(0));
});