
Web Realtime Websockets
- 10 installs
- 19 repo stars
- Updated July 19, 2026
- agents-inc/skills
web-realtime-websockets is a Claude Code skill that provides native WebSocket patterns for real-time bidirectional communication in web apps.
About
web-realtime-websockets is a Claude Code skill for building real-time bidirectional communication with the native WebSocket API. A developer uses it for chat, live dashboards, or collaborative editing that need low-latency two-way updates. It covers reconnection with exponential backoff and jitter, heartbeat/ping-pong health checks, message queuing during disconnection, binary data, and type-safe message handling with discriminated unions.
- Native WebSocket lifecycle with exponential backoff and jitter reconnection
- Type-safe messages via discriminated unions plus heartbeat/ping-pong
- Message queuing on disconnect and bfcache-safe pagehide handling
Web Realtime Websockets by the numbers
- 10 all-time installs (skills.sh)
- Ranked #1,691 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
web-realtime-websockets capabilities & compatibility
- Capabilities
- websocket client · reconnection · heartbeat · message queuing · react hooks
- Use cases
- frontend · api development
- Pricing
- Free
What web-realtime-websockets says it does
Use native WebSocket API for real-time bidirectional communication.
Always implement reconnection with exponential backoff and jitter to prevent thundering herd problems.
Open WebSocket connections prevent pages from using the browser's back/forward cache
npx skills add https://github.com/agents-inc/skills --skill web-realtime-websocketsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 19 |
| Last updated | July 19, 2026 |
| Repository | agents-inc/skills ↗ |
What it does
Build resilient real-time bidirectional WebSocket features like chat, live updates, or collaborative editing in the browser.
Who is it for?
Real-time bidirectional features like chat, live dashboards, and collaborative editing
Skip if: One-way server-to-client streaming (use SSE) or simple request-response (use HTTP/REST)
When should I use this skill?
Implementing WebSocket connections, reconnection, or message handling
What you get
A resilient WebSocket client with backoff reconnection, heartbeats, message queuing, and typed messages.
- Reconnecting WebSocket client with backoff and jitter
- Heartbeat/ping-pong health detection
- Type-safe message handling and React useWebSocket hook
By the numbers
- 4 example files (core, state-machine, binary, presence)
- 4 WebSocket lifecycle events handled
Files
WebSocket Real-Time Communication Patterns
Quick Guide: Use native WebSocket API for real-time bidirectional communication. Implement exponential backoff with jitter for reconnection. Use discriminated unions for type-safe message handling. Queue messages during disconnection for delivery on reconnect. Close connections on pagehide to allow bfcache.---
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)(You MUST implement exponential backoff with jitter for ALL reconnection logic)
(You MUST use discriminated unions with a `type` field for ALL WebSocket message types)
(You MUST queue messages during disconnection and flush on reconnect)
(You MUST implement heartbeat/ping-pong to detect dead connections)
(You MUST set `binaryType` to 'arraybuffer' when handling binary data)
(You MUST use wss:// for secure origins - browsers block ws:// on HTTPS pages except localhost)
(You MUST handle bfcache with pagehide/pageshow events)
</critical_requirements>
---
Auto-detection: WebSocket, ws://, wss://, onmessage, onopen, onclose, onerror, reconnect, heartbeat, ping, pong, real-time, bidirectional
When to use:
- Building real-time features (chat, notifications, live updates)
- Implementing bidirectional communication between client and server
- Creating live dashboards or collaborative editing features
- Streaming data updates with low latency requirements
When NOT to use:
- One-way server-to-client streaming only (use SSE instead)
- Simple request-response patterns (use HTTP/REST instead)
- When library abstractions are required (use a WebSocket wrapper library)
- When automatic backpressure handling is critical (consider WebSocketStream when widely supported)
Key patterns covered:
- WebSocket connection lifecycle management
- Reconnection with exponential backoff and jitter
- Heartbeat/ping-pong for connection health
- Message queuing during disconnection
- Type-safe message handling with discriminated unions
- Binary data handling (ArrayBuffer, Blob)
- Custom React hooks (useWebSocket)
- Authentication patterns
- Room/channel subscriptions
- bfcache compatibility
Detailed Resources:
- examples/core.md - Connection lifecycle, reconnection, heartbeat, queuing, auth, rooms, hooks
- examples/state-machine.md - Connection state machine pattern
- examples/binary.md - Binary data and file upload
- examples/presence.md - User presence detection
- reference.md - Decision frameworks, close codes, anti-patterns
---
<philosophy>
Philosophy
WebSockets provide full-duplex communication channels over a single TCP connection, enabling real-time bidirectional data flow between client and server. Unlike HTTP, WebSocket connections remain open, eliminating the overhead of repeated handshakes.
The native WebSocket API is simple but requires careful handling:
1. Connection Resilience: Networks are unreliable. Always implement reconnection with exponential backoff and jitter to prevent thundering herd problems.
2. Connection Health: Intermediate proxies and firewalls can silently drop idle connections. Heartbeats detect dead connections and keep connections alive.
3. Message Integrity: Messages sent during disconnection are lost. Queue them and flush on reconnect for reliable delivery.
4. Type Safety: WebSocket messages are untyped strings. Use discriminated unions with a shared type field for compile-time safety.
5. bfcache Compatibility: Open WebSocket connections prevent pages from using the browser's back/forward cache, degrading navigation performance. Close connections on pagehide and reconnect on pageshow when event.persisted.
Connection Lifecycle:
CONNECTING -> OPEN <-> (messages) -> CLOSING -> CLOSED
| |
(error) <- reconnect <- (close)</philosophy>
---
<patterns>
Core Patterns
Pattern 1: Basic WebSocket Connection
The native WebSocket API provides four lifecycle events: onopen, onmessage, onerror, and onclose. Always handle all four.
const WS_URL = "wss://api.example.com/ws";
const socket = new WebSocket(WS_URL);
socket.onopen = () => {
/* connection ready - safe to send */
};
socket.onmessage = (event: MessageEvent) => {
/* JSON.parse(event.data) */
};
socket.onerror = (event: Event) => {
/* always followed by onclose */
};
socket.onclose = (event: CloseEvent) => {
/* reconnect here */
};Why good: All four lifecycle events handled, typed event parameters, named constant for URL
Full implementation: examples/core.md Pattern 1
---
Pattern 2: Exponential Backoff with Jitter
Reconnection attempts must use exponential backoff with jitter to prevent all clients from reconnecting simultaneously (thundering herd problem). Cap delay at a maximum and limit total retry attempts.
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30000;
const BACKOFF_MULTIPLIER = 2;
const JITTER_FACTOR = 0.5;
function calculateBackoff(attempt: number): number {
const exponential = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
MAX_BACKOFF_MS,
);
const jitter = exponential * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponential + jitter);
}Why good: Jitter prevents thundering herd, capped maximum delay, retry limit prevents infinite loops
Full reconnecting class: examples/core.md Pattern 2
---
Pattern 3: Heartbeat/Ping-Pong
Heartbeats detect dead connections and prevent intermediate infrastructure from closing idle connections. Send a ping on an interval; if pong is not received within a timeout, consider the connection dead.
const HEARTBEAT_INTERVAL_MS = 30000;
const HEARTBEAT_TIMEOUT_MS = 10000;
// Send ping -> start timeout -> if pong received, clear timeout
// If timeout fires without pong -> connection is dead, close and reconnectWhen to use: All WebSocket connections, especially those that may be idle for extended periods or pass through NATs/proxies.
Full implementation: examples/core.md Pattern 3
---
Pattern 4: Message Queuing During Disconnection
Messages sent during disconnection are lost. Queue them and flush when connection is restored. Limit queue size to prevent unbounded memory growth.
const MAX_QUEUE_SIZE = 100;
public send(data: unknown): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
this.queueMessage(data); // Queue with size limit
}
}Why good: Queue has size limit, oldest messages dropped when full, flush on reconnect, readyState check before sending
Full implementation: examples/core.md Pattern 4
---
Pattern 5: Type-Safe Messages with Discriminated Unions
Use discriminated unions with a shared type field for compile-time type safety and exhaustive handling. Define separate types for client-to-server and server-to-client messages.
type ServerMessage =
| { type: "subscribed"; channel: string; members: string[] }
| { type: "message"; channel: string; content: string; sender: string }
| { type: "error"; code: number; message: string };
function handleServerMessage(message: ServerMessage): void {
switch (message.type) {
case "subscribed":
/* ... */ break;
case "message":
/* ... */ break;
case "error":
/* ... */ break;
default:
const exhaustiveCheck: never = message; // Compile error if case missing
}
}Why good: Discriminated union enables type narrowing, exhaustiveness check catches missing cases at compile time, separate types for client/server messages
Full implementation: examples/core.md Pattern 5
---
Pattern 6: Binary Data Handling
WebSockets support binary data via ArrayBuffer or Blob. Set binaryType to 'arraybuffer' for synchronous processing with DataView. Use instanceof ArrayBuffer to distinguish binary from text messages.
socket.binaryType = "arraybuffer";
socket.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data); // Synchronous
} else {
JSON.parse(event.data); // Text message
}
};Why good: ArrayBuffer enables synchronous DataView access, instanceof check distinguishes binary from text
Full implementation with binary protocol: examples/core.md Pattern 6
---
Pattern 7: Authentication Over WebSocket
WebSocket doesn't support custom HTTP headers. Authenticate via the first message after connection (not query string, which leaks tokens to logs). Queue application messages until auth is confirmed.
socket.onopen = () => {
socket.send(JSON.stringify({ type: "auth", token })); // First message
};
// Queue all other messages until auth_result.success receivedWhy good: Token not in URL (avoids server logs), messages queued until authenticated, explicit auth state
Full implementation: examples/core.md Pattern 7
---
Pattern 8: Room/Channel Pattern
Organize connections into logical channels for targeted message delivery. Track local room state (membership, joined status) and guard against sending to unjoined rooms.
Full implementation: examples/core.md Pattern 8
---
Pattern 9: Custom React Hook (useWebSocket)
A comprehensive custom hook encapsulating connection lifecycle, reconnection with backoff, heartbeat, message queuing, and cleanup. Exposes status, send, close, and reconnect.
Full implementation: examples/core.md Pattern 9
---
Pattern 10: Shared WebSocket Connection (Context Provider)
When multiple components need the same WebSocket, use a context provider with type-based message routing via a subscribe(type, handler) API.
Full implementation: examples/core.md Pattern 10
---
Pattern 11: bfcache Compatibility
Open WebSocket connections prevent pages from entering the browser's back/forward cache. Close on pagehide and reconnect on pageshow when event.persisted.
window.addEventListener("pagehide", () => {
socket?.close(1000, "Page hidden");
});
window.addEventListener("pageshow", (event: PageTransitionEvent) => {
if (event.persisted) {
// Page restored from bfcache - reconnect
connect();
}
});Full implementation: examples/core.md Pattern 11
</patterns>
---
<red_flags>
RED FLAGS
High Priority Issues
- No reconnection logic - Connection drops are inevitable, users see permanent disconnection
- Immediate reconnection without backoff - Causes thundering herd, overwhelming server during recovery
- No heartbeat/ping-pong - Dead connections go undetected, users think they're connected
- Untyped message handling - Runtime errors when message shapes change, impossible to refactor safely
- Sending messages without readyState check - Messages silently fail when connection is not open
- Missing cleanup on component unmount - Memory leaks, zombie connections, duplicate handlers
- Using ws:// on HTTPS pages - Browsers block insecure WebSocket on secure origins (except localhost)
- Not handling bfcache - Open connections prevent back/forward cache, degrading navigation performance
Medium Priority Issues
- No message queuing during disconnection - Messages lost during brief disconnects
- Token in WebSocket URL query string - Security risk: token visible in server logs
- Using Blob binaryType for frequent binary messages - Performance penalty from async processing
- Not handling all close event codes - Missing opportunities for smart reconnection decisions
- Single retry interval without randomization - All clients reconnect at same time after outage
- Not monitoring bufferedAmount - Sending faster than network can handle causes memory issues
Gotchas & Edge Cases
- Close code 1000 is normal closure - Don't reconnect for code 1000
- onerror is always followed by onclose - Don't duplicate error handling logic
- WebSocket doesn't support custom HTTP headers - Use first message for auth, not query string
- Use `pagehide` for cleanup, not `beforeunload` - beforeunload prevents bfcache
- Some proxies have WebSocket idle timeouts - Heartbeats prevent proxy disconnects (20-30s intervals)
- readyState changes are not synchronous - Check readyState before every send
- Binary messages need `instanceof ArrayBuffer` check - Don't assume message type
- JSON.parse can throw - Always wrap in try-catch for incoming messages
- No built-in backpressure - Check
bufferedAmountbefore sending large data - WebSocketStream is experimental - Chrome/Edge 124+ only, no Firefox/Safari support
</red_flags>
---
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST implement exponential backoff with jitter for ALL reconnection logic)
(You MUST use discriminated unions with a `type` field for ALL WebSocket message types)
(You MUST queue messages during disconnection and flush on reconnect)
(You MUST implement heartbeat/ping-pong to detect dead connections)
(You MUST set `binaryType` to 'arraybuffer' when handling binary data)
(You MUST use wss:// for secure origins - browsers block ws:// on HTTPS pages except localhost)
(You MUST handle bfcache with pagehide/pageshow events)
Failure to follow these rules will result in connection storms, lost messages, blocked connections, and degraded navigation performance.
</critical_reminders>
WebSocket - Binary Data Patterns
Binary data transfer over WebSocket including file uploads. See core.md for basic patterns.
---
Pattern 13: File Upload Over WebSocket
Chunked binary file upload with progress tracking.
// lib/websocket-file-upload.ts
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
interface UploadProgress {
fileId: string;
fileName: string;
totalChunks: number;
uploadedChunks: number;
percentage: number;
}
interface FileUploadOptions {
socket: WebSocket;
file: File;
onProgress: (progress: UploadProgress) => void;
onComplete: (fileId: string) => void;
onError: (error: string) => void;
}
export async function uploadFileOverWebSocket(
options: FileUploadOptions,
): Promise<void> {
const { socket, file, onProgress, onComplete, onError } = options;
if (socket.readyState !== WebSocket.OPEN) {
onError("WebSocket is not connected");
return;
}
const fileId = crypto.randomUUID();
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
// Send file metadata first
socket.send(
JSON.stringify({
type: "file_upload_start",
fileId,
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
totalChunks,
}),
);
// Set binary type for sending chunks
socket.binaryType = "arraybuffer";
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
// Create header: 36 bytes for UUID + 4 bytes for chunk index
const header = new ArrayBuffer(40);
const headerView = new DataView(header);
// Write fileId as bytes (simplified - in practice use proper UUID encoding)
const encoder = new TextEncoder();
const fileIdBytes = encoder.encode(fileId);
new Uint8Array(header).set(fileIdBytes.slice(0, 36), 0);
// Write chunk index
headerView.setUint32(36, chunkIndex);
// Combine header and chunk
const message = new Uint8Array(40 + arrayBuffer.byteLength);
message.set(new Uint8Array(header), 0);
message.set(new Uint8Array(arrayBuffer), 40);
socket.send(message);
onProgress({
fileId,
fileName: file.name,
totalChunks,
uploadedChunks: chunkIndex + 1,
percentage: Math.round(((chunkIndex + 1) / totalChunks) * 100),
});
}
// Send completion message
socket.send(
JSON.stringify({
type: "file_upload_complete",
fileId,
}),
);
onComplete(fileId);
}Usage
// components/file-uploader.tsx
import { useRef, useState } from "react";
import { uploadFileOverWebSocket, type UploadProgress } from "../lib/websocket-file-upload";
interface FileUploaderProps {
socket: WebSocket;
}
export function FileUploader({ socket }: FileUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [progress, setProgress] = useState<UploadProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const handleUpload = async () => {
const file = fileInputRef.current?.files?.[0];
if (!file) return;
setError(null);
await uploadFileOverWebSocket({
socket,
file,
onProgress: setProgress,
onComplete: (fileId) => {
console.log(`Upload complete: ${fileId}`);
setProgress(null);
},
onError: setError,
});
};
return (
<div>
<input type="file" ref={fileInputRef} />
<button onClick={handleUpload} disabled={!!progress}>
Upload
</button>
{progress && (
<div>
Uploading {progress.fileName}: {progress.percentage}%
({progress.uploadedChunks}/{progress.totalChunks} chunks)
</div>
)}
{error && <div data-status="error">{error}</div>}
</div>
);
}Why good: Chunked upload handles large files, progress tracking for UX, binary data for efficiency, metadata sent as JSON for readability, proper error handling
WebSocket - Core Examples
Core code examples for WebSocket real-time communication. See SKILL.md for concepts and decision guidance.
Extended patterns: See state-machine.md, binary.md, and presence.md for advanced patterns.
Patterns covered:
- Pattern 1: Basic WebSocket Connection
- Pattern 2: Exponential Backoff with Jitter
- Pattern 3: Heartbeat/Ping-Pong
- Pattern 4: Message Queuing During Disconnection
- Pattern 5: Type-Safe Messages with Discriminated Unions
- Pattern 6: Binary Data Handling
- Pattern 7: Authentication Over WebSocket
- Pattern 8: Room/Channel Pattern
- Pattern 9: Custom React Hook (useWebSocket)
- Pattern 10: Shared WebSocket Connection (Context Provider)
- Pattern 11: bfcache Compatibility (pagehide/pageshow)
---
Pattern 1: Basic WebSocket Connection
The native WebSocket API provides four lifecycle events: onopen, onmessage, onerror, and onclose.
Good Example - Complete Lifecycle
const WS_URL = "wss://api.example.com/ws";
const socket = new WebSocket(WS_URL);
socket.onopen = (event: Event) => {
console.log("Connected to WebSocket server");
// Connection is ready - safe to send messages
};
socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
console.log("Received:", data);
};
socket.onerror = (event: Event) => {
console.error("WebSocket error:", event);
// Note: onerror is always followed by onclose
};
socket.onclose = (event: CloseEvent) => {
console.log(`Connection closed: code=${event.code}, reason=${event.reason}`);
// Implement reconnection logic here
};Why good: All four lifecycle events handled, typed event parameters, named constant for URL, comments explain behavior
Bad Example - Missing Handlers
// BAD - Missing error and close handling
const socket = new WebSocket("wss://api.example.com/ws");
socket.onmessage = (event) => {
console.log(event.data);
};Why bad: Missing onopen means messages could be sent before ready, missing onerror/onclose means connection failures are silent, hardcoded URL string
---
Pattern 2: Exponential Backoff with Jitter
Reconnection attempts should use exponential backoff with jitter to prevent all clients from reconnecting simultaneously (thundering herd problem).
Constants
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 30000;
const BACKOFF_MULTIPLIER = 2;
const MAX_RETRY_ATTEMPTS = 10;
const JITTER_FACTOR = 0.5; // 50% randomnessGood Example - Reconnecting WebSocket
function calculateBackoff(attempt: number): number {
const exponentialDelay = Math.min(
INITIAL_BACKOFF_MS * Math.pow(BACKOFF_MULTIPLIER, attempt),
MAX_BACKOFF_MS,
);
// Add jitter: random value between 50% and 150% of delay
const jitter = exponentialDelay * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponentialDelay + jitter);
}
class ReconnectingWebSocket {
private socket: WebSocket | null = null;
private url: string;
private retryCount = 0;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null;
constructor(url: string) {
this.url = url;
this.connect();
}
private connect(): void {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.retryCount = 0; // Reset on successful connection
};
this.socket.onclose = (event: CloseEvent) => {
// Don't reconnect on intentional close (code 1000)
if (event.code !== 1000 && this.retryCount < MAX_RETRY_ATTEMPTS) {
this.scheduleReconnect();
}
};
}
private scheduleReconnect(): void {
const delay = calculateBackoff(this.retryCount);
this.retryCount++;
console.log(`Reconnecting in ${delay}ms (attempt ${this.retryCount})`);
this.reconnectTimeoutId = setTimeout(() => {
this.connect();
}, delay);
}
public close(): void {
if (this.reconnectTimeoutId) {
clearTimeout(this.reconnectTimeoutId);
}
this.socket?.close(1000, "Client closed"); // Normal closure
}
}Why good: Jitter prevents thundering herd, capped maximum delay prevents excessive waits, retry limit prevents infinite loops, intentional close (code 1000) skips reconnect, timeout cleaned up on close
Bad Example - No Backoff
// BAD - Immediate reconnection overwhelms server
socket.onclose = () => {
new WebSocket(url); // Thundering herd!
};Why bad: Immediate reconnection overwhelms server during outages, all clients reconnect at exact same time, no retry limit causes infinite loops
---
Pattern 3: Heartbeat/Ping-Pong
Heartbeats detect dead connections and prevent intermediate infrastructure from closing idle connections.
Good Example - Client-Side Heartbeat
const HEARTBEAT_INTERVAL_MS = 30000;
const HEARTBEAT_TIMEOUT_MS = 10000;
class HeartbeatWebSocket {
private socket: WebSocket;
private heartbeatIntervalId: ReturnType<typeof setInterval> | null = null;
private heartbeatTimeoutId: ReturnType<typeof setTimeout> | null = null;
private onConnectionLost: () => void;
constructor(url: string, onConnectionLost: () => void) {
this.socket = new WebSocket(url);
this.onConnectionLost = onConnectionLost;
this.socket.onopen = () => {
this.startHeartbeat();
};
this.socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.type === "pong") {
this.clearHeartbeatTimeout();
return;
}
// Handle other messages...
};
this.socket.onclose = () => {
this.stopHeartbeat();
};
}
private startHeartbeat(): void {
this.heartbeatIntervalId = setInterval(() => {
this.sendPing();
}, HEARTBEAT_INTERVAL_MS);
}
private sendPing(): void {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "ping" }));
// Set timeout for pong response
this.heartbeatTimeoutId = setTimeout(() => {
console.error("Heartbeat timeout - connection lost");
this.socket.close();
this.onConnectionLost();
}, HEARTBEAT_TIMEOUT_MS);
}
}
private clearHeartbeatTimeout(): void {
if (this.heartbeatTimeoutId) {
clearTimeout(this.heartbeatTimeoutId);
this.heartbeatTimeoutId = null;
}
}
private stopHeartbeat(): void {
if (this.heartbeatIntervalId) {
clearInterval(this.heartbeatIntervalId);
}
this.clearHeartbeatTimeout();
}
}Why good: Named constants for intervals, timeout detects dead connections, cleanup prevents memory leaks, pong handler clears timeout, readyState check prevents sending on closed socket
---
Pattern 4: Message Queuing During Disconnection
Messages sent during disconnection are lost. Queue them and flush when connection is restored.
Good Example - Queue with Size Limit
const MAX_QUEUE_SIZE = 100;
interface QueuedMessage {
data: unknown;
timestamp: number;
}
class QueuedWebSocket {
private socket: WebSocket | null = null;
private messageQueue: QueuedMessage[] = [];
private url: string;
constructor(url: string) {
this.url = url;
this.connect();
}
private connect(): void {
this.socket = new WebSocket(this.url);
this.socket.onopen = () => {
this.flushQueue();
};
// ... other handlers
}
public send(data: unknown): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(data));
} else {
this.queueMessage(data);
}
}
private queueMessage(data: unknown): void {
if (this.messageQueue.length >= MAX_QUEUE_SIZE) {
// Remove oldest message to make room
this.messageQueue.shift();
console.warn("Message queue full - dropping oldest message");
}
this.messageQueue.push({
data,
timestamp: Date.now(),
});
}
private flushQueue(): void {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
if (message && this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message.data));
}
}
}
}Why good: Queue has size limit to prevent memory issues, oldest messages dropped when full, flush happens on successful reconnect, readyState check before sending, timestamp allows message expiration if needed
---
Pattern 5: Type-Safe Messages with Discriminated Unions
Use discriminated unions with a shared type field for compile-time type safety and exhaustive handling.
Good Example - Discriminated Unions
// Outgoing messages (client to server)
type ClientMessage =
| { type: "subscribe"; channel: string }
| { type: "unsubscribe"; channel: string }
| { type: "message"; channel: string; content: string }
| { type: "ping" };
// Incoming messages (server to client)
type ServerMessage =
| { type: "subscribed"; channel: string; members: string[] }
| { type: "unsubscribed"; channel: string }
| { type: "message"; channel: string; content: string; sender: string }
| { type: "pong" }
| { type: "error"; code: number; message: string };
function handleServerMessage(message: ServerMessage): void {
// TypeScript narrows the type based on the `type` field
switch (message.type) {
case "subscribed":
console.log(
`Joined ${message.channel} with ${message.members.length} members`,
);
break;
case "unsubscribed":
console.log(`Left ${message.channel}`);
break;
case "message":
console.log(`${message.sender}: ${message.content}`);
break;
case "pong":
// Heartbeat response - handled elsewhere
break;
case "error":
console.error(`Error ${message.code}: ${message.message}`);
break;
default:
// Exhaustiveness check - TypeScript error if case missing
const exhaustiveCheck: never = message;
console.warn("Unknown message type:", exhaustiveCheck);
}
}
function sendMessage(socket: WebSocket, message: ClientMessage): void {
socket.send(JSON.stringify(message));
}
// Usage - TypeScript enforces correct structure
sendMessage(socket, { type: "subscribe", channel: "general" });
sendMessage(socket, { type: "message", channel: "general", content: "Hello!" });Why good: Discriminated union enables type narrowing in switch, exhaustiveness check catches missing cases at compile time, separate types for client/server messages, type-safe send function
Bad Example - Untyped Messages
// BAD - No type safety
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "message") {
// No type safety - data.content could be anything
console.log(data.content);
}
};Why bad: No compile-time type checking, typos in type strings not caught, missing fields cause runtime errors
---
Pattern 6: Binary Data Handling
WebSockets support binary data via ArrayBuffer or Blob. Use ArrayBuffer for synchronous processing.
Good Example - Binary Protocol with Headers
const BINARY_HEADER_SIZE = 8; // 4 bytes type + 4 bytes length
type BinaryMessageType = 0x01 | 0x02 | 0x03;
const BinaryMessageTypes = {
IMAGE: 0x01 as BinaryMessageType,
AUDIO: 0x02 as BinaryMessageType,
FILE: 0x03 as BinaryMessageType,
} as const;
class BinaryWebSocket {
private socket: WebSocket;
constructor(url: string) {
this.socket = new WebSocket(url);
// Set binaryType to arraybuffer for synchronous processing
this.socket.binaryType = "arraybuffer";
this.socket.onmessage = (event: MessageEvent) => {
if (event.data instanceof ArrayBuffer) {
this.handleBinaryMessage(event.data);
} else {
this.handleTextMessage(event.data);
}
};
}
private handleBinaryMessage(buffer: ArrayBuffer): void {
const view = new DataView(buffer);
// Read header (big-endian by default)
const messageType = view.getUint32(0);
const payloadLength = view.getUint32(4);
// Extract payload
const payload = buffer.slice(
BINARY_HEADER_SIZE,
BINARY_HEADER_SIZE + payloadLength,
);
switch (messageType) {
case BinaryMessageTypes.IMAGE:
this.handleImage(payload);
break;
case BinaryMessageTypes.AUDIO:
this.handleAudio(payload);
break;
case BinaryMessageTypes.FILE:
this.handleFile(payload);
break;
}
}
public sendBinary(type: BinaryMessageType, data: ArrayBuffer): void {
const header = new ArrayBuffer(BINARY_HEADER_SIZE);
const headerView = new DataView(header);
headerView.setUint32(0, type);
headerView.setUint32(4, data.byteLength);
// Combine header and payload
const message = new Uint8Array(BINARY_HEADER_SIZE + data.byteLength);
message.set(new Uint8Array(header), 0);
message.set(new Uint8Array(data), BINARY_HEADER_SIZE);
this.socket.send(message);
}
private handleTextMessage(data: string): void {
const message = JSON.parse(data);
// ...
}
private handleImage(payload: ArrayBuffer): void {
/* ... */
}
private handleAudio(payload: ArrayBuffer): void {
/* ... */
}
private handleFile(payload: ArrayBuffer): void {
/* ... */
}
}Why good: binaryType set to arraybuffer for synchronous DataView access, header with type and length for protocol parsing, typed message types, instanceof check distinguishes binary from text
Bad Example - Using Blob
// BAD - Blob forces async handling
socket.binaryType = "blob"; // Default, but forces async
socket.onmessage = async (event) => {
if (event.data instanceof Blob) {
const buffer = await event.data.arrayBuffer(); // Async overhead
}
};Why bad: Blob requires async processing, adds latency to message handling, ArrayBuffer is synchronous and faster
---
Pattern 7: Authentication Over WebSocket
WebSocket doesn't support custom HTTP headers. Authenticate via first message after connection.
Good Example - Token via First Message
interface AuthMessage {
type: "auth";
token: string;
}
interface AuthResponse {
type: "auth_result";
success: boolean;
error?: string;
}
class AuthenticatedWebSocket {
private socket: WebSocket;
private authenticated = false;
private pendingMessages: unknown[] = [];
private onAuthenticated: () => void;
private onAuthError: (error: string) => void;
constructor(
url: string,
token: string,
onAuthenticated: () => void,
onAuthError: (error: string) => void,
) {
this.socket = new WebSocket(url);
this.onAuthenticated = onAuthenticated;
this.onAuthError = onAuthError;
this.socket.onopen = () => {
// Send auth token as first message
const authMessage: AuthMessage = { type: "auth", token };
this.socket.send(JSON.stringify(authMessage));
};
this.socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.type === "auth_result") {
this.handleAuthResult(data as AuthResponse);
return;
}
if (!this.authenticated) {
console.warn("Received message before authentication");
return;
}
// Handle authenticated messages...
};
}
private handleAuthResult(response: AuthResponse): void {
if (response.success) {
this.authenticated = true;
this.flushPendingMessages();
this.onAuthenticated();
} else {
this.onAuthError(response.error || "Authentication failed");
this.socket.close();
}
}
public send(data: unknown): void {
if (!this.authenticated) {
this.pendingMessages.push(data);
return;
}
this.socket.send(JSON.stringify(data));
}
private flushPendingMessages(): void {
while (this.pendingMessages.length > 0) {
const message = this.pendingMessages.shift();
this.socket.send(JSON.stringify(message));
}
}
}Why good: Token sent as first message (not in URL - avoids server logs), messages queued until authenticated, auth response handled before other messages, explicit authenticated state, callbacks for success/error
Bad Example - Token in URL
// BAD - Token visible in server access logs
const socket = new WebSocket(`wss://api.example.com/ws?token=${token}`);Why bad: Token visible in server access logs, may be cached by proxies, URL length limits, harder to refresh token
---
Pattern 8: Room/Channel Pattern
Organize connections into logical channels for targeted message delivery.
Good Example - Room Subscriptions
interface RoomState {
id: string;
members: Set<string>;
joined: boolean;
}
type RoomServerMessage =
| { type: "room_joined"; roomId: string; members: string[] }
| { type: "room_message"; roomId: string; payload: unknown }
| { type: "member_joined"; roomId: string; memberId: string }
| { type: "member_left"; roomId: string; memberId: string };
class RoomWebSocket {
private socket: WebSocket;
private rooms: Map<string, RoomState> = new Map();
private onRoomMessage: (roomId: string, message: unknown) => void;
constructor(
url: string,
onRoomMessage: (roomId: string, message: unknown) => void,
) {
this.socket = new WebSocket(url);
this.onRoomMessage = onRoomMessage;
this.socket.onmessage = (event: MessageEvent) => {
const data: RoomServerMessage = JSON.parse(event.data);
this.handleMessage(data);
};
}
public joinRoom(roomId: string): void {
if (this.rooms.has(roomId)) {
return; // Already in room
}
this.rooms.set(roomId, {
id: roomId,
members: new Set(),
joined: false,
});
this.socket.send(
JSON.stringify({
type: "join_room",
roomId,
}),
);
}
public leaveRoom(roomId: string): void {
if (!this.rooms.has(roomId)) {
return;
}
this.socket.send(
JSON.stringify({
type: "leave_room",
roomId,
}),
);
this.rooms.delete(roomId);
}
public sendToRoom(roomId: string, message: unknown): void {
const room = this.rooms.get(roomId);
if (!room?.joined) {
console.warn(`Cannot send to room ${roomId} - not joined`);
return;
}
this.socket.send(
JSON.stringify({
type: "room_message",
roomId,
payload: message,
}),
);
}
private handleMessage(message: RoomServerMessage): void {
switch (message.type) {
case "room_joined": {
const room = this.rooms.get(message.roomId);
if (room) {
room.joined = true;
room.members = new Set(message.members);
}
break;
}
case "room_message": {
this.onRoomMessage(message.roomId, message.payload);
break;
}
case "member_joined": {
const room = this.rooms.get(message.roomId);
room?.members.add(message.memberId);
break;
}
case "member_left": {
const room = this.rooms.get(message.roomId);
room?.members.delete(message.memberId);
break;
}
}
}
}Why good: Local room state tracks membership, guards against sending to unjoined rooms, typed discriminated union for server messages, clean subscription API
---
Pattern 9: Custom React Hook (useWebSocket)
A comprehensive custom hook for WebSocket management in React applications.
Type Definitions
// types/websocket.ts
export type WebSocketStatus = "connecting" | "open" | "closing" | "closed";
export interface UseWebSocketOptions<TIn, TOut> {
url: string;
onMessage?: (message: TIn) => void;
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onError?: (event: Event) => void;
reconnect?: boolean;
reconnectAttempts?: number;
reconnectInterval?: number;
heartbeatInterval?: number;
heartbeatMessage?: TOut;
}
export interface UseWebSocketReturn<TOut> {
status: WebSocketStatus;
send: (message: TOut) => void;
close: () => void;
reconnect: () => void;
}Hook Implementation
// hooks/use-websocket.ts
import { useCallback, useEffect, useRef, useState } from "react";
import type {
UseWebSocketOptions,
UseWebSocketReturn,
WebSocketStatus,
} from "../types/websocket";
const DEFAULT_RECONNECT_ATTEMPTS = 10;
const INITIAL_RECONNECT_INTERVAL_MS = 1000;
const MAX_RECONNECT_INTERVAL_MS = 30000;
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
const HEARTBEAT_TIMEOUT_MS = 10000;
const JITTER_FACTOR = 0.5;
function calculateBackoff(attempt: number, baseInterval: number): number {
const exponentialDelay = Math.min(
baseInterval * Math.pow(2, attempt),
MAX_RECONNECT_INTERVAL_MS,
);
const jitter = exponentialDelay * JITTER_FACTOR * (Math.random() * 2 - 1);
return Math.floor(exponentialDelay + jitter);
}
export function useWebSocket<TIn, TOut>(
options: UseWebSocketOptions<TIn, TOut>,
): UseWebSocketReturn<TOut> {
const {
url,
onMessage,
onOpen,
onClose,
onError,
reconnect: shouldReconnect = true,
reconnectAttempts = DEFAULT_RECONNECT_ATTEMPTS,
reconnectInterval = INITIAL_RECONNECT_INTERVAL_MS,
heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL_MS,
heartbeatMessage,
} = options;
const [status, setStatus] = useState<WebSocketStatus>("connecting");
const socketRef = useRef<WebSocket | null>(null);
const reconnectCountRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const heartbeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const messageQueueRef = useRef<TOut[]>([]);
const mountedRef = useRef(true);
const manualCloseRef = useRef(false);
const clearHeartbeatTimeout = useCallback(() => {
if (heartbeatTimeoutRef.current) {
clearTimeout(heartbeatTimeoutRef.current);
heartbeatTimeoutRef.current = null;
}
}, []);
const stopHeartbeat = useCallback(() => {
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = null;
}
clearHeartbeatTimeout();
}, [clearHeartbeatTimeout]);
const startHeartbeat = useCallback(() => {
if (!heartbeatMessage || heartbeatInterval <= 0) return;
stopHeartbeat();
heartbeatIntervalRef.current = setInterval(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(heartbeatMessage));
heartbeatTimeoutRef.current = setTimeout(() => {
console.warn("Heartbeat timeout - closing connection");
socketRef.current?.close();
}, HEARTBEAT_TIMEOUT_MS);
}
}, heartbeatInterval);
}, [heartbeatMessage, heartbeatInterval, stopHeartbeat]);
const flushMessageQueue = useCallback(() => {
while (messageQueueRef.current.length > 0) {
const message = messageQueueRef.current.shift();
if (message && socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
}
}
}, []);
const connect = useCallback(() => {
if (!mountedRef.current) return;
setStatus("connecting");
manualCloseRef.current = false;
const socket = new WebSocket(url);
socketRef.current = socket;
socket.onopen = (event: Event) => {
if (!mountedRef.current) {
socket.close();
return;
}
setStatus("open");
reconnectCountRef.current = 0;
startHeartbeat();
flushMessageQueue();
onOpen?.(event);
};
socket.onmessage = (event: MessageEvent) => {
if (!mountedRef.current) return;
try {
const data = JSON.parse(event.data) as TIn;
// Handle heartbeat response (clear timeout)
if (
heartbeatMessage &&
(data as unknown as { type?: string }).type === "pong"
) {
clearHeartbeatTimeout();
return;
}
onMessage?.(data);
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
socket.onerror = (event: Event) => {
if (!mountedRef.current) return;
onError?.(event);
};
socket.onclose = (event: CloseEvent) => {
if (!mountedRef.current) return;
setStatus("closed");
stopHeartbeat();
onClose?.(event);
// Attempt reconnection if enabled and not manually closed
if (
shouldReconnect &&
!manualCloseRef.current &&
reconnectCountRef.current < reconnectAttempts
) {
const delay = calculateBackoff(
reconnectCountRef.current,
reconnectInterval,
);
reconnectCountRef.current++;
console.log(
`WebSocket reconnecting in ${delay}ms (attempt ${reconnectCountRef.current})`,
);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
}
};
}, [
url,
onMessage,
onOpen,
onClose,
onError,
shouldReconnect,
reconnectAttempts,
reconnectInterval,
heartbeatMessage,
startHeartbeat,
stopHeartbeat,
clearHeartbeatTimeout,
flushMessageQueue,
]);
const send = useCallback((message: TOut) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
} else {
// Queue message for delivery on reconnect
messageQueueRef.current.push(message);
}
}, []);
const close = useCallback(() => {
manualCloseRef.current = true;
setStatus("closing");
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
stopHeartbeat();
socketRef.current?.close(1000, "Client closed");
}, [stopHeartbeat]);
const reconnectManual = useCallback(() => {
close();
reconnectCountRef.current = 0;
manualCloseRef.current = false;
// Small delay before reconnecting
setTimeout(connect, 100);
}, [close, connect]);
// Initial connection
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
manualCloseRef.current = true;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
stopHeartbeat();
socketRef.current?.close(1000, "Component unmounted");
};
}, [connect, stopHeartbeat]);
return {
status,
send,
close,
reconnect: reconnectManual,
};
}Usage Example
// components/chat.tsx
import { useCallback, useState } from "react";
import { useWebSocket } from "../hooks/use-websocket";
const WS_URL = "wss://api.example.com/ws";
// Discriminated union message types
type ServerMessage =
| { type: "message"; content: string; sender: string; timestamp: number }
| { type: "user_joined"; username: string }
| { type: "user_left"; username: string }
| { type: "pong" };
type ClientMessage =
| { type: "message"; content: string }
| { type: "ping" };
interface Message {
content: string;
sender: string;
timestamp: number;
}
export function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const handleMessage = useCallback((message: ServerMessage) => {
switch (message.type) {
case "message":
setMessages((prev) => [
...prev,
{
content: message.content,
sender: message.sender,
timestamp: message.timestamp,
},
]);
break;
case "user_joined":
console.log(`${message.username} joined`);
break;
case "user_left":
console.log(`${message.username} left`);
break;
case "pong":
// Handled by hook internally
break;
}
}, []);
const { status, send } = useWebSocket<ServerMessage, ClientMessage>({
url: WS_URL,
onMessage: handleMessage,
reconnect: true,
heartbeatInterval: 30000,
heartbeatMessage: { type: "ping" },
});
const handleSend = () => {
if (inputValue.trim()) {
send({ type: "message", content: inputValue });
setInputValue("");
}
};
return (
<div>
<div>Status: {status}</div>
<ul>
{messages.map((msg, idx) => (
<li key={idx}>
<strong>{msg.sender}:</strong> {msg.content}
</li>
))}
</ul>
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
disabled={status !== "open"}
placeholder={status === "open" ? "Type a message..." : "Connecting..."}
/>
<button onClick={handleSend} disabled={status !== "open"}>
Send
</button>
</div>
);
}Why good: Hook encapsulates all WebSocket complexity, typed message generics, automatic reconnection with backoff, heartbeat included, message queueing, proper cleanup on unmount, status exposed for UI feedback
---
Pattern 10: Shared WebSocket Connection
When multiple components need the same WebSocket, use a context provider to share a single connection.
// context/websocket-context.tsx
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
const WS_URL = "wss://api.example.com/ws";
interface WebSocketContextValue {
status: "connecting" | "open" | "closed";
send: (message: unknown) => void;
subscribe: (type: string, handler: (data: unknown) => void) => () => void;
}
const WebSocketContext = createContext<WebSocketContextValue | null>(null);
export function WebSocketProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");
const socketRef = useRef<WebSocket | null>(null);
const subscribersRef = useRef<Map<string, Set<(data: unknown) => void>>>(new Map());
useEffect(() => {
const socket = new WebSocket(WS_URL);
socketRef.current = socket;
socket.onopen = () => setStatus("open");
socket.onclose = () => setStatus("closed");
socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
const messageType = data.type as string;
// Notify all subscribers for this message type
const handlers = subscribersRef.current.get(messageType);
handlers?.forEach((handler) => handler(data));
};
return () => {
socket.close(1000, "Provider unmounted");
};
}, []);
const send = (message: unknown) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
}
};
const subscribe = (type: string, handler: (data: unknown) => void) => {
if (!subscribersRef.current.has(type)) {
subscribersRef.current.set(type, new Set());
}
subscribersRef.current.get(type)!.add(handler);
// Return unsubscribe function
return () => {
subscribersRef.current.get(type)?.delete(handler);
};
};
return (
<WebSocketContext.Provider value={{ status, send, subscribe }}>
{children}
</WebSocketContext.Provider>
);
}
export function useWebSocketContext() {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error("useWebSocketContext must be used within WebSocketProvider");
}
return context;
}Component Using Shared Connection
// components/notifications.tsx
import { useEffect, useState } from "react";
import { useWebSocketContext } from "../context/websocket-context";
interface Notification {
id: string;
title: string;
message: string;
}
export function Notifications() {
const { subscribe } = useWebSocketContext();
const [notifications, setNotifications] = useState<Notification[]>([]);
useEffect(() => {
// Subscribe to notification messages
const unsubscribe = subscribe("notification", (data) => {
const notification = data as { type: "notification" } & Notification;
setNotifications((prev) => [...prev, notification]);
});
return unsubscribe;
}, [subscribe]);
return (
<ul>
{notifications.map((n) => (
<li key={n.id}>
<strong>{n.title}</strong>: {n.message}
</li>
))}
</ul>
);
}Why good: Single WebSocket connection shared across components, type-based message routing, automatic cleanup with unsubscribe function, context prevents prop drilling
---
Pattern 11: bfcache Compatibility
Open WebSocket connections can prevent pages from entering the browser's back/forward cache, degrading navigation performance. Handle pagehide and pageshow events to manage connections properly.
// hooks/use-bfcache-websocket.ts
import { useCallback, useEffect, useRef, useState } from "react";
const WS_URL = "wss://api.example.com/ws";
interface UseBfcacheWebSocketReturn {
status: "connecting" | "open" | "closed";
send: (message: unknown) => void;
}
export function useBfcacheWebSocket(): UseBfcacheWebSocketReturn {
const [status, setStatus] = useState<"connecting" | "open" | "closed">(
"connecting",
);
const socketRef = useRef<WebSocket | null>(null);
const messageQueueRef = useRef<unknown[]>([]);
const connect = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
return;
}
setStatus("connecting");
const socket = new WebSocket(WS_URL);
socketRef.current = socket;
socket.onopen = () => {
setStatus("open");
// Flush queued messages on reconnect
while (messageQueueRef.current.length > 0) {
const msg = messageQueueRef.current.shift();
socket.send(JSON.stringify(msg));
}
};
socket.onclose = () => {
setStatus("closed");
};
socket.onmessage = (event) => {
// Handle incoming messages
console.log("Received:", event.data);
};
}, []);
const disconnect = useCallback(() => {
socketRef.current?.close(1000, "Page hidden");
socketRef.current = null;
setStatus("closed");
}, []);
const send = useCallback((message: unknown) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify(message));
} else {
messageQueueRef.current.push(message);
}
}, []);
useEffect(() => {
connect();
// Close WebSocket on pagehide to allow bfcache
const handlePageHide = () => {
disconnect();
};
// Reconnect on pageshow if page was restored from bfcache
const handlePageShow = (event: PageTransitionEvent) => {
if (event.persisted) {
// Page restored from bfcache - reconnect
connect();
}
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("pageshow", handlePageShow);
return () => {
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("pageshow", handlePageShow);
disconnect();
};
}, [connect, disconnect]);
return { status, send };
}Why good: Closes WebSocket on pagehide allowing bfcache, reconnects on pageshow when persisted (restored from cache), queues messages during disconnection, clean event listener management
Bad Example - Blocks bfcache
// BAD - No pagehide handling, blocks bfcache
useEffect(() => {
const socket = new WebSocket(WS_URL);
return () => socket.close();
}, []);Why bad: Open WebSocket connections prevent bfcache, users experience slower back/forward navigation, connection stays open when page is hidden wasting resources
WebSocket - Presence Detection
Track online users and their activity status. See core.md for basic patterns.
---
Pattern 14: Presence Detection
Track online users and their activity status.
// hooks/use-presence.ts
import { useCallback, useEffect, useRef, useState } from "react";
const ACTIVITY_TIMEOUT_MS = 30000; // 30 seconds
const PRESENCE_UPDATE_INTERVAL_MS = 10000; // 10 seconds
type UserStatus = "online" | "away" | "offline";
interface UserPresence {
id: string;
username: string;
status: UserStatus;
lastSeen: number;
}
interface UsePresenceOptions {
socket: WebSocket;
userId: string;
username: string;
}
export function usePresence({ socket, userId, username }: UsePresenceOptions) {
const [users, setUsers] = useState<Map<string, UserPresence>>(new Map());
const [myStatus, setMyStatus] = useState<UserStatus>("online");
const lastActivityRef = useRef(Date.now());
const updateIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Send presence update to server
const sendPresenceUpdate = useCallback(
(status: UserStatus) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(
JSON.stringify({
type: "presence_update",
userId,
username,
status,
}),
);
}
},
[socket, userId, username],
);
// Track user activity
const updateActivity = useCallback(() => {
lastActivityRef.current = Date.now();
if (myStatus !== "online") {
setMyStatus("online");
sendPresenceUpdate("online");
}
}, [myStatus, sendPresenceUpdate]);
// Listen for activity events
useEffect(() => {
const events = ["mousedown", "keydown", "touchstart", "scroll"];
events.forEach((event) => {
document.addEventListener(event, updateActivity);
});
return () => {
events.forEach((event) => {
document.removeEventListener(event, updateActivity);
});
};
}, [updateActivity]);
// Check for inactivity
useEffect(() => {
const checkActivity = () => {
const timeSinceActivity = Date.now() - lastActivityRef.current;
if (timeSinceActivity > ACTIVITY_TIMEOUT_MS && myStatus === "online") {
setMyStatus("away");
sendPresenceUpdate("away");
}
};
updateIntervalRef.current = setInterval(
checkActivity,
PRESENCE_UPDATE_INTERVAL_MS,
);
return () => {
if (updateIntervalRef.current) {
clearInterval(updateIntervalRef.current);
}
};
}, [myStatus, sendPresenceUpdate]);
// Handle presence messages from server
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case "presence_update":
setUsers((prev) => {
const next = new Map(prev);
next.set(data.userId, {
id: data.userId,
username: data.username,
status: data.status,
lastSeen: Date.now(),
});
return next;
});
break;
case "user_disconnected":
setUsers((prev) => {
const next = new Map(prev);
next.delete(data.userId);
return next;
});
break;
case "presence_list":
// Initial presence list from server
setUsers(
new Map((data.users as UserPresence[]).map((u) => [u.id, u])),
);
break;
}
};
socket.addEventListener("message", handleMessage);
return () => socket.removeEventListener("message", handleMessage);
}, [socket]);
// Send offline on unmount
useEffect(() => {
return () => {
sendPresenceUpdate("offline");
};
}, [sendPresenceUpdate]);
return {
users: Array.from(users.values()),
myStatus,
onlineCount: Array.from(users.values()).filter((u) => u.status === "online")
.length,
};
}Why good: Activity detection for away status, presence updates sent periodically, offline sent on unmount, clean event listener management, Map for efficient user lookups
WebSocket - State Machine Pattern
Connection state machine for managing complex WebSocket states. See core.md for basic patterns.
---
Pattern 12: Connection State Machine
A robust state machine for managing complex WebSocket connection states.
// lib/websocket-state-machine.ts
type ConnectionState =
| { status: "idle" }
| { status: "connecting" }
| { status: "connected"; connectedAt: number }
| { status: "reconnecting"; attempt: number; nextRetryAt: number }
| { status: "disconnected"; reason: string }
| { status: "failed"; error: string; attempts: number };
type ConnectionEvent =
| { type: "CONNECT" }
| { type: "CONNECTED" }
| { type: "DISCONNECT"; reason: string }
| { type: "ERROR"; error: string }
| { type: "RETRY_SCHEDULED"; attempt: number; delay: number }
| { type: "MAX_RETRIES_REACHED" };
const MAX_RETRY_ATTEMPTS = 10;
function connectionReducer(
state: ConnectionState,
event: ConnectionEvent,
): ConnectionState {
switch (event.type) {
case "CONNECT":
if (
state.status === "idle" ||
state.status === "disconnected" ||
state.status === "failed"
) {
return { status: "connecting" };
}
return state;
case "CONNECTED":
return { status: "connected", connectedAt: Date.now() };
case "DISCONNECT":
if (state.status === "connected" || state.status === "connecting") {
return { status: "disconnected", reason: event.reason };
}
return state;
case "ERROR":
return { status: "disconnected", reason: event.error };
case "RETRY_SCHEDULED":
return {
status: "reconnecting",
attempt: event.attempt,
nextRetryAt: Date.now() + event.delay,
};
case "MAX_RETRIES_REACHED":
if (state.status === "reconnecting") {
return {
status: "failed",
error: "Maximum retry attempts reached",
attempts: MAX_RETRY_ATTEMPTS,
};
}
return state;
default:
return state;
}
}
// Usage with React useReducer
import { useReducer } from "react";
function useConnectionState() {
const [state, dispatch] = useReducer(connectionReducer, { status: "idle" });
const connect = () => dispatch({ type: "CONNECT" });
const connected = () => dispatch({ type: "CONNECTED" });
const disconnect = (reason: string) =>
dispatch({ type: "DISCONNECT", reason });
const error = (err: string) => dispatch({ type: "ERROR", error: err });
const scheduleRetry = (attempt: number, delay: number) =>
dispatch({ type: "RETRY_SCHEDULED", attempt, delay });
const maxRetriesReached = () => dispatch({ type: "MAX_RETRIES_REACHED" });
return {
state,
connect,
connected,
disconnect,
error,
scheduleRetry,
maxRetriesReached,
};
}Why good: Explicit state transitions, impossible states are impossible, type-safe events, clear state history for debugging, reducible and testable
# yaml-language-server: $schema=https://raw.githubusercontent.com/agents-inc/cli/main/src/schemas/metadata.schema.json
category: web-realtime
slug: websockets
domain: web
author: "@vince"
displayName: WebSockets
cliDescription: Bidirectional real-time communication
usageGuidance: Use when implementing WebSocket connections, reconnection, or message handling.
WebSocket Reference
Decision frameworks, anti-patterns, and quick-reference tables for WebSocket real-time communication. See SKILL.md for core concepts and red flags, examples/ for code examples.
---
Decision Framework
When to Use WebSocket vs Alternatives
Need real-time communication?
├─ YES → Is it bidirectional (client sends to server)?
│ ├─ YES → Is low latency critical?
│ │ ├─ YES → WebSocket ✓
│ │ └─ NO → WebSocket or polling (depending on complexity)
│ └─ NO → Server-Sent Events (SSE) for one-way server→client
└─ NO → Use HTTP REST for request-response patternsNative WebSocket vs Libraries
Building WebSocket features?
├─ Need library-managed features (rooms, namespaces, auto-transport fallback)?
│ └─ YES → Use a WebSocket wrapper library (not this skill's scope)
├─ Need simple bidirectional communication?
│ └─ YES → Native WebSocket API
├─ Need to support legacy browsers without WebSocket?
│ └─ YES → Use a library with fallback transports
└─ Default → Native WebSocket API for simplicityConnection Management Strategy
Managing WebSocket connections?
├─ Multiple components need same connection?
│ └─ YES → Use Context Provider (Pattern 10)
├─ Single component needs connection?
│ └─ YES → Use custom hook in component
├─ Complex state transitions?
│ └─ YES → Use state machine pattern (Pattern 12)
└─ Simple connection → Basic WebSocket classMessage Serialization Strategy
Choosing message format?
├─ Need human-readable debugging?
│ └─ YES → JSON with discriminated unions
├─ Bandwidth/performance critical?
│ └─ YES → Binary with ArrayBuffer (Pattern 6)
├─ Mixed text and binary data?
│ └─ YES → JSON for control, binary for data
└─ Default → JSON with discriminated unionsReconnection Strategy
Implementing reconnection?
├─ Server might be temporarily down?
│ └─ YES → Exponential backoff with jitter ✓
├─ Connection drops are expected?
│ └─ YES → Message queuing + flush on reconnect ✓
├─ Need to limit server load after outage?
│ └─ YES → Max retry limit + backoff cap ✓
└─ All WebSocket connections → Always implement reconnectionBinary Data Strategy
Handling binary data?
├─ Need synchronous processing?
│ └─ YES → binaryType = 'arraybuffer' ✓
├─ Working with files/blobs?
│ └─ YES → Consider chunked uploads (Pattern 13)
├─ Need protocol with headers?
│ └─ YES → DataView for parsing binary headers
└─ Default → binaryType = 'arraybuffer' for performance---
Anti-Patterns
These anti-patterns cover scenarios not fully illustrated with code in the core examples.
No Cleanup on Unmount
React components must clean up WebSocket connections to prevent memory leaks.
// WRONG - No cleanup
useEffect(() => {
const socket = new WebSocket(url);
// ... handlers
}, []); // Memory leak! Socket stays open
// CORRECT - Cleanup on unmount
useEffect(() => {
const socket = new WebSocket(url);
// ... handlers
return () => {
socket.close(1000, "Component unmounted");
};
}, []);Not Handling Intentional Close
Reconnecting after intentional close wastes resources and confuses users.
// WRONG - Reconnects even on intentional close
socket.onclose = () => {
reconnect(); // Even when user clicked "disconnect"
};
// CORRECT - Track intentional close
let intentionalClose = false;
function close() {
intentionalClose = true;
socket.close(1000, "User requested");
}
socket.onclose = (event) => {
if (!intentionalClose && event.code !== 1000) {
reconnect();
}
};Ignoring bufferedAmount (Backpressure)
Sending data faster than the network can handle causes memory issues.
const MAX_BUFFER_SIZE = 1024 * 1024; // 1MB
// WRONG - No backpressure check
function sendLargeData(data: ArrayBuffer) {
socket.send(data); // May queue unbounded data
}
// CORRECT - Check bufferedAmount before sending
function sendLargeData(data: ArrayBuffer): boolean {
if (socket.bufferedAmount > MAX_BUFFER_SIZE) {
console.warn("Buffer full, try again later");
return false;
}
socket.send(data);
return true;
}---
Close Event Codes Reference
| Code | Name | Description | Reconnect? |
|---|---|---|---|
| 1000 | Normal Closure | Clean close, intentional | No |
| 1001 | Going Away | Server shutting down, page navigating away | Yes |
| 1002 | Protocol Error | Protocol violation | No - fix client |
| 1003 | Unsupported Data | Received data type not supported | No - fix client |
| 1006 | Abnormal Closure | No close frame received (network issue) | Yes |
| 1007 | Invalid Data | Message data inconsistent with type | No - fix client |
| 1008 | Policy Violation | Generic policy violation | Maybe - check reason |
| 1009 | Message Too Big | Message size exceeds limit | No - fix client |
| 1010 | Missing Extension | Expected extension not negotiated | No - fix client |
| 1011 | Internal Error | Server encountered unexpected condition | Yes |
| 1012 | Service Restart | Server restarting | Yes (with backoff) |
| 1013 | Try Again Later | Server overloaded | Yes (with longer backoff) |
| 1014 | Bad Gateway | Proxy/gateway error | Yes |
| 1015 | TLS Handshake | TLS handshake failure | No - fix certificates |
---
Quick Reference
WebSocket ReadyState Values
| Value | Constant | Description |
|---|---|---|
| 0 | WebSocket.CONNECTING | Connection not yet established |
| 1 | WebSocket.OPEN | Connection established, ready to communicate |
| 2 | WebSocket.CLOSING | Connection closing |
| 3 | WebSocket.CLOSED | Connection closed |
Connection Checklist
- [ ] Implements exponential backoff with jitter
- [ ] Has maximum retry limit
- [ ] Has heartbeat/ping-pong mechanism (20-30s intervals recommended)
- [ ] Queues messages during disconnection
- [ ] Flushes queue on reconnect
- [ ] Checks readyState before sending
- [ ] Cleans up on component unmount
- [ ] Distinguishes intentional vs unintentional close
- [ ] Handles bfcache (pagehide/pageshow events)
Message Handling Checklist
- [ ] Uses discriminated unions for message types
- [ ] Has exhaustive switch with never check
- [ ] Wraps JSON.parse in try-catch
- [ ] Checks instanceof for binary vs text
- [ ] Uses ArrayBuffer for binary data (not Blob)
Security Checklist
- [ ] Uses wss:// (not ws://) in production
- [ ] Token sent as first message (not in URL)
- [ ] Validates server messages before using
- [ ] Handles authentication expiry/refresh
Performance Checklist
- [ ] Uses binaryType = 'arraybuffer' for binary data
- [ ] Chunks large file uploads
- [ ] Limits message queue size
- [ ] Uses shared connection when multiple components need same socket
- [ ] Monitors bufferedAmount for backpressure on large sends
Related skills
FAQ
How do I prevent all clients reconnecting at once after an outage?
Use exponential backoff with jitter and a capped maximum delay plus a retry limit to avoid the thundering herd problem.
Why do open WebSockets hurt navigation performance?
Open connections block the browser's back/forward cache (bfcache); close on pagehide and reconnect on pageshow when event.persisted is true.