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.
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)
// 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;
}
}
// 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();
// 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.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));
});