Reconnection Strategies and Exponential Backoff
Network disconnections are inevitable—Wi-Fi drops, mobile handovers, server restarts, and timeout errors happen in production. Exponential backoff is a retry pattern where you wait longer between each reconnection attempt: wait 1 second, then 2, then 4, then 8, up to a maximum (e.g., 30 seconds). This prevents the server from being hammered by reconnecting clients and gives intermittent network issues time to resolve (AWS Best Practices, 2026).
Understanding Exponential Backoff
Exponential backoff formula: delay = Math.min(baseDelay * Math.pow(2, retries), maxDelay). For baseDelay = 1000 ms and maxDelay = 30000 ms:
- Retry 1: 1 second
- Retry 2: 2 seconds
- Retry 3: 4 seconds
- Retry 4: 8 seconds
- Retry 5: 16 seconds
- Retry 6+: 30 seconds (capped)
Why this matters: if 100,000 clients disconnect simultaneously and all retry immediately, they create a thundering herd—a spike of reconnection requests that crashes the server. With exponential backoff, reconnection attempts spread over minutes, allowing the server to recover gracefully (Crater Labs, 2026).
Implementing Reconnection in React
Here's a production-grade useWebSocket hook with exponential backoff:
import { useEffect, useRef, useState } from 'react';
export function useWebSocket(url, options = {}) {
const {
baseDelay = 1000,
maxDelay = 30000,
maxRetries = 5,
onMessage = () => {},
onOpen = () => {},
onError = () => {},
onClose = () => {},
} = options;
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const retryCountRef = useRef(0);
const retryTimeoutRef = useRef(null);
const connect = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
return; // Already connected
}
try {
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
console.log('[WebSocket] Connected');
setConnected(true);
retryCountRef.current = 0; // Reset retry count on success
onOpen();
});
ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
onMessage(data);
} catch (error) {
console.error('[WebSocket] Parse error:', error);
}
});
ws.addEventListener('error', (error) => {
console.error('[WebSocket] Error:', error);
onError(error);
});
ws.addEventListener('close', () => {
console.log('[WebSocket] Closed, will retry');
setConnected(false);
onClose();
scheduleReconnect();
});
wsRef.current = ws;
} catch (error) {
console.error('[WebSocket] Connection failed:', error);
scheduleReconnect();
}
};
const scheduleReconnect = () => {
if (retryCountRef.current >= maxRetries) {
console.error('[WebSocket] Max retries exceeded, giving up');
return;
}
const delay = Math.min(
baseDelay * Math.pow(2, retryCountRef.current),
maxDelay
);
retryCountRef.current += 1;
console.log(
`[WebSocket] Reconnecting in ${delay}ms (attempt ${retryCountRef.current})`
);
retryTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
};
// Initialize connection on mount
useEffect(() => {
connect();
// Cleanup on unmount
return () => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [url]);
const send = (data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
} else {
console.warn('[WebSocket] Not connected, message not sent');
}
};
return { connected, send, ws: wsRef.current };
}
Usage in a component:
export function ReliableChat() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const { connected, send } = useWebSocket(
'wss://your-server.example.com/chat',
{
baseDelay: 1000,
maxDelay: 30000,
maxRetries: 5,
onMessage: (data) => {
setMessages((prev) => [...prev, data]);
},
onOpen: () => {
console.log('Connected, ready to chat');
},
onClose: () => {
console.log('Disconnected, will retry');
},
}
);
const handleSend = () => {
send({
type: 'message',
text: input,
timestamp: Date.now(),
});
setInput('');
};
return (
`<div>`
`<p>`Status: {connected ? '🟢 Connected' : '🔴 Disconnected'}`</p>`
`<div>`
{messages.map((msg, idx) => (
`<div key={idx}>`{msg.text}`</div>`
))}
`</div>`
`<input`
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={!connected}
`/>`
`<button onClick={handleSend} disabled={!connected}>`
Send
`</button>`
`</div>`
);
}
Adding Jitter to Prevent Thundering Herd
When many clients disconnect simultaneously, they all retry at the same times (1s, 2s, 4s). This still creates periodic spikes. Add random jitter to spread reconnection attempts:
const delay = Math.min(
baseDelay * Math.pow(2, retryCountRef.current),
maxDelay
);
// Add jitter: ±25% random variation
const jitter = delay * 0.25 * (Math.random() - 0.5) * 2;
const finalDelay = Math.max(delay + jitter, 100); // At least 100ms
console.log(`Reconnecting in ${finalDelay}ms`);
retryTimeoutRef.current = setTimeout(() => {
connect();
}, finalDelay);
Handling Different Failure Scenarios
Different errors warrant different responses. A 403 authentication error usually means the token expired—don't retry; ask the user to log in again. A 503 server error means the server is temporarily down—exponential backoff is appropriate.
const scheduleReconnect = (error) => {
// Don't retry on client errors (4xx)
if (error && error.code && error.code >= 4000 && error.code < 5000) {
console.error('[WebSocket] Client error, not retrying:', error.code);
onError(new Error('Authentication failed. Please log in again.'));
return;
}
// Exponential backoff for server errors (5xx) and network errors
const delay = Math.min(
baseDelay * Math.pow(2, retryCountRef.current),
maxDelay
);
retryCountRef.current += 1;
console.log(`Reconnecting in ${delay}ms...`);
retryTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
};
Testing Reconnection Logic
To test reconnection in development, manually close the server and observe your client:
- Open a component with the hook.
- In the browser DevTools Network tab, throttle to offline.
- Observe console logs showing retry attempts.
- Throttle back to online and see automatic reconnection.
Or trigger disconnection programmatically:
const { connected, send, ws } = useWebSocket('wss://...');
const simulateDisconnect = () => {
if (ws) {
ws.close(); // Trigger reconnection logic
}
};
Key Takeaways
- Exponential backoff spreads reconnection attempts over time, preventing server overload.
- Always reset retry count on successful connection; never give up without a maximum.
- Add jitter (random variation) to spread attempts among many clients.
- Different errors (4xx vs 5xx) warrant different handling; don't retry permanently on client errors.
- Test reconnection logic by simulating offline/online transitions in DevTools.
Frequently Asked Questions
What's a good max retry count?
For most apps, 5–10 retries is reasonable. 5 retries with exponential backoff reaches 16 seconds; 10 retries reaches 16+ minutes. For critical apps like financial dashboards, aim for 10–15.
Should I notify the user when reconnecting?
Yes, for transparency. Show a small "Reconnecting..." indicator or badge. Don't use aggressive popups; they're annoying if reconnection succeeds in seconds.
Can I manually retry instead of automatic backoff?
Yes, expose a reconnect() function and let the user click a button. But also keep automatic backoff in the background for network hiccups.
How do I handle partial messages during disconnection?
Store unsent messages in a queue and retry sending after reconnection. This is the next level; see the query cache integration article for patterns.