WebSockets Deep Dive: Real-Time Communication in JavaScript

deep-diveApril 13, 2026· 5 min read

You've built a chat app, a live dashboard, or a multiplayer game — and HTTP requests feel wrong. You're polling every 5 seconds, burning through bandwidth, and the data is always a little stale. That's the problem WebSockets solve.

I've used WebSockets in production for real-time trading dashboards, collaborative editors, and notification systems. Here's everything I wish I knew from the start.

How WebSockets Work

WebSockets upgrade a standard HTTP connection into a persistent, full-duplex TCP connection. The flow looks like this:

  1. Client sends an HTTP GET request with an Upgrade: websocket header
  2. Server responds with 101 Switching Protocols
  3. The TCP connection stays open — both sides can send frames at any time
  4. No more HTTP overhead (no headers on every message)
Client → Server:  GET /chat HTTP/1.1
                  Upgrade: websocket
                  Connection: Upgrade
                  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

Server → Client:  HTTP/1.1 101 Switching Protocols
                  Upgrade: websocket
                  Connection: Upgrade
                  Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the handshake, it's not HTTP anymore. It's a raw TCP socket with a lightweight framing protocol. Messages flow in both directions with minimal overhead — about 2-10 bytes of framing per message compared to hundreds of bytes of HTTP headers.

The Browser WebSocket API

The native browser API is straightforward:

const socket = new WebSocket('wss://example.com/chat');

socket.onopen = () => {
  console.log('Connected');
  socket.send(JSON.stringify({ type: 'join', room: 'general' }));
};

socket.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

socket.onerror = (error) => {
  console.error('WebSocket error:', error);
};

socket.onclose = (event) => {
  console.log(`Closed: code=${event.code} reason=${event.reason}`);
  // event.code tells you WHY it closed
  // 1000 = normal, 1001 = going away, 1006 = abnormal (no close frame)
};

Key things to know:

  • wss:// is the secure version (always use this in production)
  • socket.send() accepts strings, ArrayBuffer, or Blob
  • socket.readyState gives you the current state (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED)
  • The onclose event's code field is critical for debugging — see the full list of close codes

Using the ws Library (Node.js)

On the server side, the ws package is the standard choice. It's fast, well-maintained, and handles the HTTP upgrade for you.

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws, request) => {
  console.log(`New client from ${request.socket.remoteAddress}`);

  ws.on('message', (data) => {
    // data is a Buffer by default
    const message = JSON.parse(data.toString());
    console.log('Received:', message);

    // Echo back or broadcast
    ws.send(JSON.stringify({ echo: message }));
  });

  ws.on('close', (code, reason) => {
    console.log(`Client left: ${code}`);
  });

  ws.on('error', (err) => {
    console.error('Client error:', err);
  });
});

For broadcasting to all connected clients:

function broadcast(wss, data) {
  const payload = JSON.stringify(data);
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  });
}

Connection Lifecycle

Understanding the lifecycle prevents most bugs I see in production:

CONNECTING (0) → OPEN (1) → CLOSING (2) → CLOSED (3)

Common mistakes:

  • Calling send() before onopen fires — message silently drops
  • Not handling onclose — your app thinks it's still connected
  • Ignoring readyState — always check before sending

A robust send function:

function safeSend(socket, data) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    return true;
  }
  console.warn('Socket not open, state:', socket.readyState);
  return false;
}

Reconnection Strategies

Connections drop. Network switches, server restarts, mobile signal loss — you need reconnection logic. Here's a production-grade approach with exponential backoff:

class ReconnectingWebSocket {
  constructor(url, options = {}) {
    this.url = url;
    this.maxRetries = options.maxRetries ?? Infinity;
    this.maxDelay = options.maxDelay ?? 30000;
    this.baseDelay = options.baseDelay ?? 1000;
    this.retries = 0;
    this.socket = null;
    this.manualClose = false;
    this.handlers = {};

    this.connect();
  }

  connect() {
    this.socket = new WebSocket(this.url);

    this.socket.onopen = (e) => {
      this.retries = 0;
      this.emit('open', e);
    };

    this.socket.onmessage = (e) => this.emit('message', e);

    this.socket.onclose = (e) => {
      if (!this.manualClose) {
        this.reconnect();
      }
      this.emit('close', e);
    };

    this.socket.onerror = (e) => this.emit('error', e);
  }

  reconnect() {
    if (this.retries >= this.maxRetries) return;

    const delay = Math.min(
      this.baseDelay * Math.pow(2, this.retries) + Math.random() * 1000,
      this.maxDelay
    );
    this.retries++;

    console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.retries})`);
    setTimeout(() => this.connect(), delay);
  }

  send(data) {
    if (this.socket?.readyState === WebSocket.OPEN) {
      this.socket.send(typeof data === 'string' ? data : JSON.stringify(data));
    }
  }

  close() {
    this.manualClose = true;
    this.socket?.close();
  }

  on(event, handler) {
    this.handlers[event] = this.handlers[event] || [];
    this.handlers[event].push(handler);
  }

  emit(event, data) {
    this.handlers[event]?.forEach((h) => h(data));
  }
}

The jitter (Math.random() * 1000) prevents the "thundering herd" problem — if 1000 clients all disconnect at once, they won't all reconnect simultaneously.

Heartbeat / Ping-Pong

Silent disconnections are a real problem. A connection can look open but the TCP link is dead (think: laptop suspended). The solution is heartbeats.

The WebSocket protocol has built-in ping/pong frames, but the browser API doesn't expose them. For browsers, implement application-level heartbeats:

// Client-side heartbeat
let heartbeatInterval;
let missedPings = 0;
const MAX_MISSED = 3;

function startHeartbeat(socket) {
  missedPings = 0;
  heartbeatInterval = setInterval(() => {
    if (socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify({ type: 'ping' }));
      missedPings++;
      if (missedPings > MAX_MISSED) {
        console.warn('Too many missed heartbeats, closing');
        socket.close();
      }
    }
  }, 30000); // every 30 seconds
}

function handleMessage(data) {
  if (data.type === 'pong') {
    missedPings = 0; // server is alive
    return;
  }
  // handle normal messages
}

On the server with ws, you can use the built-in ping/pong:

const wss = new WebSocketServer({
  port: 8080,
  clientTracking: true,
});

// Server-side ping every 30s
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate();
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; });
});

Binary Data

WebSockets handle binary frames natively. This is useful for file transfers, streaming audio, or game state:

// Sending binary data
const buffer = new Uint8Array([1, 2, 3, 4, 5]);
socket.send(buffer);

// Receiving — set binaryType first
socket.binaryType = 'arraybuffer'; // or 'blob'

socket.onmessage = (event) => {
  if (typeof event.data === 'string') {
    // text frame
  } else {
    // binary frame — event.data is an ArrayBuffer
    const view = new DataView(event.data);
    console.log('First byte:', view.getUint8(0));
  }
};

For file uploads over WebSocket, chunk the data:

async function sendFile(socket, file) {
  const CHUNK_SIZE = 64 * 1024; // 64KB chunks
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE);

  for (let i = 0; i < totalChunks; i++) {
    const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
    const buffer = await chunk.arrayBuffer();
    socket.send(buffer);
  }

  socket.send(JSON.stringify({ type: 'file-complete', name: file.name }));
}

WebSocket vs SSE vs HTTP Polling

This is the most common architectural question. Here's when to use each:

WebSocketsSSEHTTP Polling
DirectionFull-duplexServer → Client onlyClient pulls
Protocolws:// / wss://HTTPHTTP
Best forChat, games, collabNotifications, feedsRarely the right choice
ReconnectionYou build itBuilt-inN/A (stateless)
Binary✅ Native❌ Text only✅ Via HTTP
Proxy/FirewallSometimes blockedHTTP (safe)HTTP (safe)
ComplexityMedium-HighLowLowest

Use WebSockets when:

  • You need real-time bidirectional communication (chat, multiplayer, live trading)
  • Message frequency is high (many messages per second)
  • Low latency matters

Use SSE when:

  • Data flows server → client only (notifications, live scores, activity feeds)
  • You want simplicity — SSE is just a long-lived HTTP response
  • You need automatic reconnection for free
// SSE is this simple
const source = new EventSource('/api/notifications');
source.onmessage = (event) => {
  console.log(JSON.parse(event.data));
};
source.onerror = () => console.log('Reconnecting...');

Use HTTP polling when:

  • Nothing else works (restrictive corporate proxies)
  • Updates are infrequent (every 30+ seconds)
  • You need it working in 5 minutes and don't care about efficiency

Key Takeaways

  • WebSockets upgrade HTTP to a persistent TCP connection — full-duplex, low overhead after handshake
  • Always use wss:// in production — unencrypted WebSocket traffic is trivially intercepted
  • Build reconnection with exponential backoff and jitter — connections will drop, plan for it
  • Implement heartbeats — detect dead connections before they cause bugs
  • Use SSE for server → client only — it's simpler and handles reconnection for you
  • Check readyState before sending — sending to a closed socket silently fails
  • The ws library is the standard for Node.js servers — use it, don't roll your own parser

The WebSocket API is simple. Building a reliable real-time system on top of it is not. Handle reconnection, heartbeats, and connection state properly, and you'll save yourself from the most common production incidents I've seen.