BytePane

WebSockets Guide: Real-Time Communication for Web Apps

Web Development14 min read

Why WebSockets Exist

HTTP was designed for document retrieval: the client asks, the server responds. But modern web applications need the server to push data to the client in real time -- chat messages, stock prices, live sports scores, collaborative editing cursors, and multiplayer game states. Before WebSockets, developers used polling (repeated HTTP requests every N seconds) or long-polling (keeping HTTP connections open), both of which are inefficient and add latency.

WebSockets solve this by upgrading an HTTP connection to a persistent, bidirectional channel. Once established, both sides can send messages at any time with minimal overhead (2-14 bytes per frame vs hundreds of bytes for HTTP headers).

// HTTP Polling: Wasteful, high latency
setInterval(async () => {
  const res = await fetch('/api/messages?since=last_id')
  // Most responses: 200 OK with empty array (no new messages)
  // Wastes bandwidth and server resources
}, 3000)

// WebSocket: Efficient, instant delivery
const ws = new WebSocket('wss://api.example.com/ws')
ws.onmessage = (event) => {
  // Server pushes messages the instant they arrive
  // Zero wasted requests, sub-millisecond latency
  const message = JSON.parse(event.data)
  displayMessage(message)
}

The WebSocket Handshake

A WebSocket connection starts as a standard HTTP request with an Upgrade header. The server responds with HTTP 101 Switching Protocols, and the connection transitions from HTTP to the WebSocket protocol.

# Client request (HTTP → WebSocket upgrade)
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat, json
Origin: https://example.com

# Server response (upgrade accepted)
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: json

# After this handshake, the connection uses the WebSocket
# binary framing protocol — no more HTTP headers per message

Client-Side JavaScript API

The browser's WebSocket API is straightforward. Create a connection, listen for events, and send messages. The API handles the binary framing protocol automatically.

// Create a WebSocket connection
const ws = new WebSocket('wss://api.example.com/ws')

// Connection opened
ws.addEventListener('open', () => {
  console.log('Connected to server')

  // Send a JSON message
  ws.send(JSON.stringify({
    type: 'subscribe',
    channel: 'live-updates',
  }))
})

// Receive messages
ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data)

  switch (data.type) {
    case 'chat_message':
      displayMessage(data.payload)
      break
    case 'user_joined':
      updateUserList(data.payload)
      break
    case 'typing':
      showTypingIndicator(data.payload.userId)
      break
  }
})

// Connection closed
ws.addEventListener('close', (event) => {
  console.log(`Disconnected: ${event.code} ${event.reason}`)
  // Implement reconnection logic here
})

// Error handling
ws.addEventListener('error', (error) => {
  console.error('WebSocket error:', error)
})

// Send data (string or binary)
ws.send('Hello, server!')                     // Text
ws.send(new Blob(['binary data']))            // Blob
ws.send(new Uint8Array([1, 2, 3]).buffer)     // ArrayBuffer

// Close the connection gracefully
ws.close(1000, 'User navigated away')

Server-Side Implementation (Node.js)

The most popular WebSocket library for Node.js is ws, which provides a clean API for handling connections, broadcasting messages, and managing rooms.

import { WebSocketServer } from 'ws'
import { createServer } from 'http'

const server = createServer()
const wss = new WebSocketServer({ server })

// Track connected clients
const clients = new Map()

wss.on('connection', (ws, req) => {
  const userId = authenticateFromHeaders(req.headers)
  clients.set(userId, ws)
  console.log(`User ${userId} connected. Total: ${clients.size}`)

  // Send welcome message
  ws.send(JSON.stringify({
    type: 'welcome',
    payload: { userId, onlineUsers: clients.size },
  }))

  // Handle incoming messages
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString())

    switch (message.type) {
      case 'chat_message':
        // Broadcast to all connected clients
        broadcast({
          type: 'chat_message',
          payload: {
            userId,
            text: message.text,
            timestamp: Date.now(),
          },
        })
        break

      case 'direct_message':
        // Send to specific user
        const recipient = clients.get(message.toUserId)
        if (recipient?.readyState === 1) {
          recipient.send(JSON.stringify({
            type: 'direct_message',
            payload: { from: userId, text: message.text },
          }))
        }
        break
    }
  })

  // Handle disconnect
  ws.on('close', () => {
    clients.delete(userId)
    broadcast({
      type: 'user_left',
      payload: { userId, onlineUsers: clients.size },
    })
  })

  // Heartbeat to detect stale connections
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })
})

// Broadcast to all connected clients
function broadcast(message) {
  const data = JSON.stringify(message)
  wss.clients.forEach((client) => {
    if (client.readyState === 1) client.send(data)
  })
}

// Heartbeat interval: detect and clean up dead connections
setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate()
    ws.isAlive = false
    ws.ping()
  })
}, 30000)

server.listen(3001)

Reconnection with Exponential Backoff

WebSocket connections can drop due to network changes, server restarts, or load balancer timeouts. Robust applications implement automatic reconnection with exponential backoff to avoid overwhelming the server.

class ReconnectingWebSocket {
  private ws: WebSocket | null = null
  private retryCount = 0
  private maxRetries = 10
  private baseDelay = 1000  // 1 second

  constructor(private url: string) {
    this.connect()
  }

  private connect() {
    this.ws = new WebSocket(this.url)

    this.ws.onopen = () => {
      console.log('Connected')
      this.retryCount = 0  // Reset on successful connection
    }

    this.ws.onclose = (event) => {
      if (event.code === 1000) return  // Normal close, don't reconnect

      if (this.retryCount < this.maxRetries) {
        // Exponential backoff: 1s, 2s, 4s, 8s, 16s... max 30s
        const delay = Math.min(
          this.baseDelay * Math.pow(2, this.retryCount),
          30000
        )
        // Add jitter to prevent thundering herd
        const jitter = delay * 0.2 * Math.random()
        const totalDelay = delay + jitter

        console.log(`Reconnecting in ${(totalDelay/1000).toFixed(1)}s (attempt ${this.retryCount + 1})`)
        setTimeout(() => this.connect(), totalDelay)
        this.retryCount++
      } else {
        console.error('Max reconnection attempts reached')
      }
    }

    this.ws.onerror = () => {} // onclose will fire after onerror
  }

  send(data: string) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(data)
    }
  }

  close() {
    this.ws?.close(1000, 'Client closing')
  }
}

Authentication and Security

The browser WebSocket API does not support custom headers during the handshake, which means you cannot send an Authorization header like you would with HTTP. There are two common workarounds for authenticating WebSocket connections.

// Method 1: Token in URL query parameter
const token = getAuthToken()
const ws = new WebSocket(`wss://api.example.com/ws?token=${token}`)

// Server validates during upgrade:
wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'wss://api.example.com')
  const token = url.searchParams.get('token')
  const user = verifyJWT(token)
  if (!user) {
    ws.close(4001, 'Unauthorized')
    return
  }
})

// Method 2: Authenticate after connection (preferred)
const ws = new WebSocket('wss://api.example.com/ws')
ws.onopen = () => {
  // First message must be auth
  ws.send(JSON.stringify({
    type: 'auth',
    token: getAuthToken(),
  }))
}

// Server side:
ws.on('message', (data) => {
  const msg = JSON.parse(data)
  if (!ws.authenticated) {
    if (msg.type === 'auth') {
      const user = verifyJWT(msg.token)
      if (user) {
        ws.authenticated = true
        ws.userId = user.id
        ws.send(JSON.stringify({ type: 'auth_success' }))
      } else {
        ws.close(4001, 'Invalid token')
      }
    } else {
      ws.close(4002, 'Authentication required')
    }
    return
  }
  // Handle authenticated messages...
})

Inspect your authentication tokens during development with our JWT Decoder. For a deeper comparison of JWT and session-based authentication, see our JWT vs session cookies guide.

Socket.IO vs Native WebSockets

Socket.IO is a library that wraps WebSockets with additional features. It is not a WebSocket implementation; it uses its own protocol on top of WebSockets (or HTTP long-polling as fallback).

FeatureNative WebSocketSocket.IO
Bundle size0 KB (built-in)~20 KB (gzipped)
Auto-reconnectManual implementationBuilt-in with backoff
Rooms/namespacesManual implementationBuilt-in
Fallback transportNoneHTTP long-polling
Event namingSingle "message" eventCustom event names
Binary supportNativeAuto-detected
InteropStandard protocolSocket.IO clients only

Scaling with Redis Pub/Sub

A single WebSocket server handles thousands of connections, but horizontal scaling requires coordinating messages across multiple server instances. Redis pub/sub acts as a message bus between servers.

import Redis from 'ioredis'
import { WebSocketServer } from 'ws'

const pub = new Redis()  // Publisher
const sub = new Redis()  // Subscriber

const wss = new WebSocketServer({ port: 3001 })

// Subscribe to Redis channel
sub.subscribe('chat:broadcast')
sub.on('message', (channel, message) => {
  // Relay to all local WebSocket clients
  wss.clients.forEach((client) => {
    if (client.readyState === 1) {
      client.send(message)
    }
  })
})

// When a local client sends a message, publish to Redis
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString())
    if (msg.type === 'chat_message') {
      // Publish to Redis → all server instances receive it
      pub.publish('chat:broadcast', JSON.stringify({
        type: 'chat_message',
        payload: msg.payload,
        serverId: process.env.SERVER_ID,
      }))
    }
  })
})

// Architecture:
// Client A → Server 1 → Redis pub/sub → Server 2 → Client B
//                                     → Server 3 → Client C

WebSocket Close Codes

CodeNameMeaning
1000Normal ClosureConnection fulfilled its purpose
1001Going AwayServer shutting down or page navigating
1006Abnormal ClosureConnection lost without close frame
1008Policy ViolationMessage violates server policy
1011Internal ErrorUnexpected server error
4000-4999Application-definedCustom codes for your app (e.g., 4001 = unauthorized)

Debug Real-Time Apps with BytePane

Format WebSocket message payloads with our JSON Formatter. Decode authentication tokens with the JWT Decoder. Compare message schemas with the Diff Checker.

Open JSON Formatter

Related Articles