Error Handling and Debugging Real-Time Apps
WebSocket errors are silent failures: a connection drops, no message arrives, and the UI freezes. Unlike REST APIs, you can't wrap every call in a try-catch. Comprehensive error handling requires logging every state change, monitoring for stale connections, and building observability dashboards so you detect issues before users report them (Crater Labs, 2026).
WebSocket Error Types and Recovery
Not all errors are equal. Categorize them and respond accordingly:
| Error | Cause | Recovery |
|---|---|---|
| Connection refused | Server offline | Exponential backoff (article 4) |
| Network timeout | Latency spike or packet loss | Automatic reconnect with backoff |
| 401 Unauthorized | Token expired | Refresh auth, reconnect |
| 403 Forbidden | Insufficient permissions | Silently close; notify user |
| 503 Service Unavailable | Server overloaded | Backoff and retry |
| Frame parsing error | Malformed message from server | Log and ignore message |
| Memory exhaustion | Too many connections | Close oldest connections |
Structured Error Logging
Log every error with context so you can debug production issues:
export class WebSocketErrorTracker {
constructor(userId, roomId) {
this.userId = userId;
this.roomId = roomId;
this.errors = [];
}
log(error, context) {
const entry = {
timestamp: new Date().toISOString(),
userId: this.userId,
roomId: this.roomId,
error: error.message || String(error),
code: error.code,
stack: error.stack,
context, // Connection state, last message, etc.
};
this.errors.push(entry);
// Send to logging service (e.g., Sentry, LogRocket)
this.reportToServer(entry);
console.error('[WebSocket Error]', entry);
}
reportToServer(entry) {
// Example: POST to /api/logs
navigator.sendBeacon('/api/logs', JSON.stringify(entry));
}
}
export function useWebSocket(url, options = {}) {
const errorTrackerRef = useRef(
new WebSocketErrorTracker('user-123', 'room-123')
);
useEffect(() => {
const ws = new WebSocket(url);
ws.addEventListener('error', (event) => {
errorTrackerRef.current.log(event, {
readyState: ws.readyState,
url: ws.url,
lastMessageTime: lastMessageTimeRef.current,
});
// Handle different error scenarios
if (event.code === 1006) {
// Abnormal closure
console.log('Abnormal closure, will retry');
scheduleReconnect();
} else if (event.code === 1008) {
// Policy violation (e.g., 403)
console.log('Forbidden; not retrying');
return;
}
});
return () => ws.close();
}, [url]);
}
Heartbeat Monitoring for Stale Connections
A connection can appear open but be dead (network partition, hanging proxy). Implement heartbeat monitoring:
export function useWebSocketWithHeartbeat(url, options = {}) {
const [isAlive, setIsAlive] = useState(true);
const heartbeatTimeoutRef = useRef(null);
useEffect(() => {
const ws = new WebSocket(url);
const resetHeartbeat = () => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
// Wait 45 seconds for a server ping; if none, assume dead
heartbeatTimeoutRef.current = setTimeout(() => {
console.warn('Heartbeat timeout; connection is stale');
setIsAlive(false);
ws.close(); // Force reconnection
}, 45000);
};
ws.addEventListener('open', () => {
setIsAlive(true);
resetHeartbeat();
});
ws.addEventListener('message', (event) => {
// Any message counts as heartbeat
resetHeartbeat();
});
ws.addEventListener('ping', () => {
// Server ping received; respond with pong
ws.pong();
resetHeartbeat();
});
ws.addEventListener('close', () => {
setIsAlive(false);
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
});
return () => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
}
ws.close();
};
}, [url]);
return isAlive;
}
Client-Side Validation
Never trust server messages. Parse and validate:
function parseMessage(data) {
try {
const msg = JSON.parse(data);
// Validate required fields
if (!msg.type) {
throw new Error('Missing "type" field');
}
if (msg.type === 'message') {
if (!msg.text || typeof msg.text !== 'string') {
throw new Error('Invalid message text');
}
if (!Number.isInteger(msg.timestamp)) {
throw new Error('Invalid timestamp');
}
}
return msg;
} catch (error) {
console.error('Message validation error:', error);
return null;
}
}
// In your message handler
ws.addEventListener('message', (event) => {
const msg = parseMessage(event.data);
if (!msg) return; // Skip invalid messages
onMessage(msg);
});
Testing Error Scenarios
To test error handling, use the Network tab in DevTools:
- Simulate offline: DevTools > Network > Offline. Observe reconnection.
- Throttle connection: Network > Slow 3G. Test timeout behavior.
- Block WebSocket: Network > filter by "WS" > right-click > Block request URL. Trigger error handling.
- Inspect messages: Network > WS > Frames tab. See every message sent/received.
Or test programmatically:
export function useWebSocketWithTestModeListener(url, testMode = false) {
useEffect(() => {
if (!testMode) return;
const handleKeyDown = (e) => {
// Press 'D' to simulate disconnect
if (e.key === 'd') {
console.log('[Test] Simulating disconnect');
// Call your disconnect handler
}
// Press 'E' to simulate error
if (e.key === 'e') {
console.log('[Test] Simulating error');
// Trigger error handler
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [testMode]);
}
Debugging with Browser DevTools
WebSocket Inspector
Use specialized DevTools extensions:
- Simple WebSocket Client (Chrome): Send/receive test messages.
- WebSocket Monitor (Chrome): See all frames with timing.
- Thunderclient (VS Code): WebSocket debugging.
Console Logging
Log message flow with prefixes for easy filtering:
const DEBUG = true;
function log(prefix, ...args) {
if (DEBUG) {
console.log(`[${prefix}] ${new Date().toISOString()}`, ...args);
}
}
ws.addEventListener('open', () => {
log('WS', 'Connected');
});
ws.addEventListener('message', (e) => {
log('WS-RX', e.data);
});
ws.addEventListener('close', () => {
log('WS', 'Closed');
});
Then filter DevTools console: filter: [WS-RX] to see only received messages.
Rate Limiting and Backpressure
If the client sends too many messages, the server may disconnect. Implement client-side rate limiting:
export class MessageQueue {
constructor(maxPerSecond = 10) {
this.maxPerSecond = maxPerSecond;
this.tokens = maxPerSecond;
this.queue = [];
}
async send(message) {
return new Promise((resolve) => {
this.queue.push({ message, resolve });
this.process();
});
}
process() {
while (this.tokens > 0 && this.queue.length > 0) {
const { message, resolve } = this.queue.shift();
this.tokens--;
ws.send(JSON.stringify(message));
resolve();
// Refill token every 1/maxPerSecond seconds
setTimeout(() => {
this.tokens++;
this.process();
}, 1000 / this.maxPerSecond);
}
}
}
const queue = new MessageQueue(10); // Max 10 messages/second
const handleSend = () => {
queue.send({
type: 'message',
text: input,
});
};
Key Takeaways
- Categorize errors (connection, auth, server, validation) and respond appropriately.
- Log every error with context (timestamp, userId, connection state) for debugging.
- Implement heartbeat monitoring to detect stale connections before they break the UX.
- Validate all server messages; never trust input.
- Test error scenarios using DevTools Network tab and programmatic test modes.
Frequently Asked Questions
How do I detect a hung WebSocket connection?
Implement heartbeat monitoring. If no message arrives in 45 seconds, assume the connection is dead and reconnect. The server should send periodic pings (WebSocket control frame).
Should I log every message?
In production, no—it's too verbose and affects performance. Log only errors, connections, and disconnections. For debugging, enable verbose logging via a URL query param or localStorage flag.
How do I handle a 403 error when the token expires?
Catch the error, refresh the token (via a REST call to /api/refresh-token), and reconnect the WebSocket. Or use a custom WebSocket subprotocol to send Sec-WebSocket-Protocol: bearer-<token>.
Can I retry a failed message?
Yes, queue failed messages and retry with backoff. But be careful of duplicates; use idempotency keys (message IDs) so the server can deduplicate.