Intermediate
20 min
Full Guide

WebSocket Fundamentals

Learn how WebSocket protocol works, its lifecycle, and when to use it over HTTP

What is WebSocket?

WebSocket is a communication protocol that provides full-duplex (two-way) communication channels over a single, long-lived TCP connection. Unlike HTTP's request-response model, WebSocket allows both client and server to send messages independently at any time.

WebSocket was standardized in 2011 (RFC 6455) and is now supported by all modern browsers. It's the foundation for real-time features in chat apps, games, trading platforms, and collaborative tools.

🔄 HTTP vs WebSocket

HTTP (Request-Response)

Client: GET /api/messages
Server: 200 OK [messages]
        (connection closed)

Client: POST /api/message
Server: 201 Created
        (connection closed)

# New connection for each request!

WebSocket (Persistent)

Client: Upgrade to WebSocket
Server: 101 Switching Protocols
        (connection stays open)

Client: {"type": "message", ...}
Server: {"type": "message", ...}
Server: {"type": "notification", ...}
Client: {"type": "typing", ...}

# Same connection, messages flow both ways!

The WebSocket Handshake

WebSocket connections start with an HTTP "upgrade" request. This allows WebSocket to work through HTTP infrastructure (proxies, load balancers) before switching protocols.

// Client sends HTTP upgrade request:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com

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

// Now the connection speaks WebSocket protocol, not HTTP!

Understanding the Handshake Headers

// Key handshake components:

// 1. Upgrade headers - Required for protocol switch
{
  "Upgrade": "websocket",      // Tell server we want WebSocket
  "Connection": "Upgrade"       // This is an upgrade request
}

// 2. Security key - Prevents cache poisoning attacks
{
  "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="  // Random base64
}

// Server must hash this with magic GUID and return:
// Sec-WebSocket-Accept = Base64(SHA1(Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))

// 3. Version - Protocol version (always 13 for RFC 6455)
{
  "Sec-WebSocket-Version": "13"
}

// 4. Optional: Subprotocols and extensions
{
  "Sec-WebSocket-Protocol": "chat, superchat",  // App-level protocols
  "Sec-WebSocket-Extensions": "permessage-deflate"  // Compression
}

WebSocket Frame Structure

After the handshake, data is transmitted in "frames". Understanding frames helps debug issues and optimize performance.

// WebSocket Frame Format (simplified):
//
//  0                   1                   2                   3
//  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-------+-+-------------+-------------------------------+
// |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
// |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
// |N|V|V|V|       |S|             |   (if payload len==126/127)   |
// | |1|2|3|       |K|             |                               |
// +-+-+-+-+-------+-+-------------+-------------------------------+
// |     Extended payload length continued, if payload len == 127  |
// +-------------------------------+-------------------------------+
// |                               |Masking-key, if MASK set to 1  |
// +-------------------------------+-------------------------------+
// | Masking-key (continued)       |          Payload Data         |
// +-------------------------------+-------------------------------+
// |                     Payload Data continued ...                |
// +---------------------------------------------------------------+

// Frame types (opcodes):
const OPCODES = {
  0x0: 'Continuation',  // Fragment of previous message
  0x1: 'Text',          // UTF-8 text data
  0x2: 'Binary',        // Binary data
  0x8: 'Close',         // Close connection
  0x9: 'Ping',          // Keepalive ping
  0xA: 'Pong'           // Keepalive pong
};

// Key points:
// - FIN bit indicates if this is the final fragment
// - Client-to-server frames MUST be masked
// - Server-to-client frames must NOT be masked
// - Payload length can be 7, 16, or 64 bits

WebSocket Connection Lifecycle

// Complete lifecycle of a WebSocket connection

// 1. CONNECTING - Initial state when WebSocket is created
const socket = new WebSocket('wss://api.example.com/ws');
console.log(socket.readyState); // 0 = CONNECTING

// 2. OPEN - Handshake complete, ready for communication
socket.onopen = (event) => {
  console.log(socket.readyState); // 1 = OPEN
  console.log('Connection established!');
  
  // Now safe to send messages
  socket.send(JSON.stringify({ type: 'hello' }));
};

// 3. MESSAGE - Receiving data
socket.onmessage = (event) => {
  // event.data can be string, Blob, or ArrayBuffer
  if (typeof event.data === 'string') {
    const message = JSON.parse(event.data);
    handleMessage(message);
  } else {
    // Binary data
    handleBinaryData(event.data);
  }
};

// 4. CLOSING - Connection is being closed
// Triggered after close() is called or server initiates close

// 5. CLOSED - Connection is fully closed
socket.onclose = (event) => {
  console.log(socket.readyState); // 3 = CLOSED
  console.log('Close code:', event.code);
  console.log('Close reason:', event.reason);
  console.log('Was clean close:', event.wasClean);
};

// ERROR - Something went wrong
socket.onerror = (error) => {
  console.error('WebSocket error:', error);
  // Note: error event is always followed by close event
};

// ReadyState constants
WebSocket.CONNECTING = 0;
WebSocket.OPEN = 1;
WebSocket.CLOSING = 2;
WebSocket.CLOSED = 3;

Sending Different Data Types

// WebSocket supports text and binary data

// 1. Text (UTF-8 strings)
socket.send('Hello, World!');
socket.send(JSON.stringify({ 
  type: 'message', 
  content: 'Hello!' 
}));

// 2. ArrayBuffer (raw binary)
const buffer = new ArrayBuffer(16);
const view = new Uint8Array(buffer);
view[0] = 0x48; // 'H'
view[1] = 0x69; // 'i'
socket.send(buffer);

// 3. Blob (binary with MIME type)
const blob = new Blob(['Hello'], { type: 'text/plain' });
socket.send(blob);

// 4. TypedArray (views into ArrayBuffer)
const floats = new Float32Array([1.0, 2.5, 3.14]);
socket.send(floats);

// Setting binary type for receiving
socket.binaryType = 'arraybuffer'; // or 'blob' (default)

socket.onmessage = (event) => {
  if (event.data instanceof ArrayBuffer) {
    // Handle binary data
    const view = new DataView(event.data);
    console.log(view.getFloat32(0));
  }
};

Closing Connections

// Graceful close with code and reason
socket.close(1000, 'User logged out');

// Common close codes:
const CLOSE_CODES = {
  1000: 'Normal closure',           // Clean close
  1001: 'Going away',               // Page navigation, server shutdown
  1002: 'Protocol error',           // Invalid frame received
  1003: 'Unsupported data',         // Received data type not supported
  1006: 'Abnormal closure',         // No close frame received (network issue)
  1007: 'Invalid frame payload',    // Non-UTF8 in text frame
  1008: 'Policy violation',         // Generic policy error
  1009: 'Message too big',          // Frame exceeds size limit
  1010: 'Extension required',       // Client expected extension
  1011: 'Internal error',           // Unexpected server error
  1015: 'TLS handshake failure'     // Failed to establish TLS
};

// Handling close event
socket.onclose = (event) => {
  if (event.code === 1000) {
    console.log('Normal close');
  } else if (event.code === 1006) {
    console.log('Connection lost - attempting reconnect');
    reconnect();
  } else {
    console.log(`Closed with code ${event.code}: ${event.reason}`);
  }
};

Ping/Pong Keepalive

// WebSocket has built-in ping/pong frames for keepalive
// Note: Browser API doesn't expose ping/pong directly!

// Server sends ping, browser automatically responds with pong
// This happens at the protocol level

// For application-level keepalive (visible in JS):
class HeartbeatWebSocket {
  constructor(url) {
    this.url = url;
    this.heartbeatInterval = 30000; // 30 seconds
    this.connect();
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => this.startHeartbeat();
    this.ws.onclose = () => this.stopHeartbeat();
    this.ws.onmessage = (e) => this.handleMessage(e);
  }
  
  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
        
        // Expect pong within 5 seconds
        this.pongTimeout = setTimeout(() => {
          console.log('Pong timeout - connection dead');
          this.ws.close();
        }, 5000);
      }
    }, this.heartbeatInterval);
  }
  
  handleMessage(event) {
    const data = JSON.parse(event.data);
    
    if (data.type === 'pong') {
      clearTimeout(this.pongTimeout);
      return;
    }
    
    // Handle other messages...
  }
  
  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    clearTimeout(this.pongTimeout);
  }
}

WebSocket URL Schemes

// WebSocket URLs use ws:// or wss:// (secure)

// Unsecured (like HTTP)
const ws = new WebSocket('ws://example.com/socket');

// Secured with TLS (like HTTPS) - ALWAYS use this in production!
const wss = new WebSocket('wss://example.com/socket');

// With path and query parameters
const wsWithParams = new WebSocket(
  'wss://api.example.com/ws/chat?room=general&token=abc123'
);

// Port specification
const wsWithPort = new WebSocket('wss://api.example.com:8080/ws');

// Common patterns
const patterns = {
  // Dedicated WebSocket server
  dedicated: 'wss://ws.example.com/connect',
  
  // Same server, different path
  samePath: 'wss://example.com/api/websocket',
  
  // Versioned endpoint
  versioned: 'wss://api.example.com/v2/realtime',
  
  // Room/channel in path
  withRoom: 'wss://chat.example.com/rooms/general',
  
  // Auth token in query (common but not ideal)
  withToken: `wss://api.example.com/ws?token=${authToken}`
};

⚠️ WebSocket Security Considerations

  • Always use WSS in production - WS is unencrypted!
  • Don't pass tokens in URLs - They appear in logs. Use first message or cookies instead.
  • Validate Origin header - Prevent cross-site WebSocket hijacking.
  • Validate all incoming data - Treat WebSocket messages like any user input.
  • Implement rate limiting - Prevent message flooding attacks.
  • Set message size limits - Prevent memory exhaustion.

Browser Support and Polyfills

// WebSocket is supported in all modern browsers
// No polyfills needed for basic functionality

// Feature detection
if ('WebSocket' in window) {
  // WebSocket supported
  const ws = new WebSocket(url);
} else {
  // Fallback to polling (very rare case)
  useLongPolling();
}

// For older environments, Socket.io provides fallbacks:
// 1. WebSocket (preferred)
// 2. HTTP long-polling
// 3. HTTP polling

// Connection limits to be aware of:
// - Browsers limit connections per domain (typically 6 for HTTP, varies for WS)
// - Use a single WebSocket connection when possible
// - Multiplex channels over one connection

💡 Key Takeaways

  • WebSocket is a protocol - Not just an API, it's a true bidirectional protocol over TCP
  • Starts as HTTP - The handshake uses HTTP, making it proxy-friendly
  • Persistent connection - One connection for all messages (no overhead per message)
  • Full duplex - Both sides can send simultaneously
  • Text and binary - Supports UTF-8 strings and raw binary data
  • Built-in keepalive - Protocol-level ping/pong frames