Skip to main content

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:

ErrorCauseRecovery
Connection refusedServer offlineExponential backoff (article 4)
Network timeoutLatency spike or packet lossAutomatic reconnect with backoff
401 UnauthorizedToken expiredRefresh auth, reconnect
403 ForbiddenInsufficient permissionsSilently close; notify user
503 Service UnavailableServer overloadedBackoff and retry
Frame parsing errorMalformed message from serverLog and ignore message
Memory exhaustionToo many connectionsClose 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:

  1. Simulate offline: DevTools > Network > Offline. Observe reconnection.
  2. Throttle connection: Network > Slow 3G. Test timeout behavior.
  3. Block WebSocket: Network > filter by "WS" > right-click > Block request URL. Trigger error handling.
  4. 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.

Further Reading