home / skills / agents-inc / skills / web-realtime-websockets

web-realtime-websockets skill

/src/skills/web-realtime-websockets

This skill helps you implement robust WebSocket real-time patterns with backoff, heartbeat, queues, and type-safe messages for reliable client-server

npx playbooks add skill agents-inc/skills --skill web-realtime-websockets

Review the files below or copy the command above to add this skill to your agents.

Files (7)
SKILL.md
24.1 KB
---
name: web-realtime-websockets
description: Native WebSocket API patterns, connection lifecycle, reconnection strategies, heartbeat, message typing, binary data, custom hooks
---

# 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.

---

<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 for synchronous processing)**

**(You MUST use wss:// for secure origins - modern browsers block ws:// on HTTPS pages except localhost)**

**(You MUST handle bfcache compatibility with pagehide/pageshow events for better navigation performance)**

</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

**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

**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 Socket.IO or similar)
- When automatic backpressure handling is critical (consider WebSocketStream when widely supported)

**Detailed Resources:**

- For code examples, see [examples/](examples/)
- For decision frameworks and anti-patterns, see [reference.md](reference.md)

---

<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.

**Connection Lifecycle:**

```
CONNECTING → OPEN ↔ (messages) → CLOSING → CLOSED
                ↓                    ↓
            (error) ← reconnect ← (close)
```

5. **bfcache Compatibility:** Open WebSocket connections can prevent pages from using the browser's back/forward cache, degrading navigation performance. Close connections on `pagehide` and reconnect on `pageshow` when persisted.

</philosophy>

---

<patterns>

## Core Patterns

### Pattern 1: Basic WebSocket Connection

The native WebSocket API provides four lifecycle events: `onopen`, `onmessage`, `onerror`, and `onclose`.

```typescript
// ✅ Good Example - Complete lifecycle handling
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

```typescript
// ❌ Bad Example - 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

```typescript
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% randomness
```

#### Implementation

```typescript
// ✅ Good Example - Exponential backoff with jitter
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

```typescript
// ❌ Bad Example - No backoff, no jitter
socket.onclose = () => {
  // Immediately reconnect - causes thundering herd
  new WebSocket(url);
};
```

**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.

```typescript
// ✅ Good Example - Client-side heartbeat implementation
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

**When to use:** All WebSocket connections, especially those that may be idle for extended periods or pass through NATs/proxies.

---

### Pattern 4: Message Queuing During Disconnection

Messages sent during disconnection are lost. Queue them and flush when connection is restored.

```typescript
// ✅ Good Example - Message queue with flush on reconnect
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.

```typescript
// ✅ Good Example - Discriminated unions for message types

// 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

```typescript
// ❌ Bad Example - Untyped message handling
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.

```typescript
// ✅ Good Example - Binary data with ArrayBuffer

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 {
    // Handle JSON messages
    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

```typescript
// ❌ Bad Example - Using Blob (async only)
socket.binaryType = "blob"; // Default, but forces async handling

socket.onmessage = async (event) => {
  if (event.data instanceof Blob) {
    // Must use async API - less performant
    const buffer = await event.data.arrayBuffer();
    // Process buffer...
  }
};
```

**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 query string or first message after connection.

```typescript
// ✅ Good Example - Token authentication 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

```typescript
// ❌ Bad Example - Token in query string
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.

```typescript
// ✅ Good Example - Room subscription pattern

interface RoomState {
  id: string;
  members: Set<string>;
  joined: boolean;
}

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 = 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(data: unknown): void {
    const message = data as {
      type: string;
      roomId?: string;
      [key: string]: unknown;
    };

    switch (message.type) {
      case "room_joined": {
        const room = this.rooms.get(message.roomId!);
        if (room) {
          room.joined = true;
          room.members = new Set(message.members as string[]);
        }
        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 as string);
        break;
      }
      case "member_left": {
        const room = this.rooms.get(message.roomId!);
        room?.members.delete(message.memberId as string);
        break;
      }
    }
  }
}
```

**Why good:** Local room state tracks membership, guards against sending to unjoined rooms, member tracking for presence, clean subscription API

</patterns>

---

<integration>

## Integration Guide

**WebSocket is transport-agnostic.** This skill covers the native WebSocket API only. Integration with specific frameworks or libraries is handled by their respective skills.

**Works with:**

- Your React framework via custom hooks (see Pattern 9 in examples/)
- Your state management solution for connection state
- Your authentication system for token management

**Defers to:**

- Backend WebSocket server implementation (backend skills)
- Socket.IO library patterns (socket-io skill if exists)
- Server-Sent Events for one-way streams (SSE skill if exists)
- State management for storing received data (state management skills)

**Alternative APIs (Future):**

- **WebSocketStream** (experimental) - Promise-based API with automatic backpressure handling via Streams API. Not widely supported yet - check browser compatibility before use.

</integration>

---

<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 for synchronous processing)**

**(You MUST use wss:// for secure origins - modern browsers block ws:// on HTTPS pages except localhost)**

**(You MUST close WebSocket on `pagehide` and reconnect on `pageshow` when `event.persisted` to allow bfcache)**

**Failure to follow these rules will result in connection storms, lost messages, blocked connections, and degraded navigation performance.**

</critical_reminders>

Overview

This skill documents robust native WebSocket patterns for real-time bidirectional communication. It focuses on connection lifecycle management, reliable reconnection, heartbeat health checks, message typing, binary handling, and queuing to avoid lost messages. The guidance is practical and ready to embed into client-side agents and hooks.

How this skill works

The skill inspects WebSocket usage and prescribes a reconnect strategy using exponential backoff with jitter, mandatory heartbeat (ping/pong) to detect dead sockets, and a message queue that buffers outgoing messages while disconnected and flushes them on reconnect. It enforces discriminated-union message typing (shared type field), sets binaryType to 'arraybuffer' for binary flows, and handles pagehide/pageshow for back/forward cache compatibility.

When to use it

  • Building low-latency bidirectional features (chat, collaboration, live dashboards)
  • Streaming frequent updates where HTTP polling is inefficient
  • When you need guaranteed delivery ordering for client-sent events across reconnects
  • Handling binary streams (audio, protobufs, images) where synchronous processing is required
  • Embedding WebSocket management into React hooks or small agent subservices

Best practices

  • Always use wss:// on secure origins; allow ws:// only for localhost during development
  • Implement exponential backoff with jitter and a retry cap to avoid thundering-herd reconnections
  • Use discriminated unions with a `type` field for all message schemas and exhaustive switch handling
  • Queue outbound messages while disconnected, cap queue size, and flush on successful reconnect
  • Run a heartbeat: send ping regularly, expect pong within a timeout, and close+reconnect on failure
  • Set socket.binaryType = 'arraybuffer' for binary messages and handle pagehide/pageshow to support bfcache

Example use cases

  • A chat agent that must resend user messages queued during temporary network loss
  • A live analytics dashboard streaming real-time metrics and recovering from intermittent connectivity
  • A collaborative editor that uses typed messages for operational transforms and ensures reconnection integrity
  • A voice-transcription pipeline sending binary audio frames with arraybuffer handling and heartbeat monitoring

FAQ

Why use exponential backoff with jitter?

Backoff with jitter spreads reconnection attempts across clients to avoid a thundering-herd effect and respects server stability while capping maximum delay.

How should I handle messages while the socket is closed?

Buffer them in a capped queue with oldest-message eviction and flush in order after the socket opens; include timestamps to expire stale entries if needed.

What is the heartbeat pattern for?

Regular ping/pong detects silent connection drops caused by proxies or NATs and triggers a clean reconnect when the peer stops responding.