WebSockets Guide: Real-Time Communication for Web Apps
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 messageClient-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).
| Feature | Native WebSocket | Socket.IO |
|---|---|---|
| Bundle size | 0 KB (built-in) | ~20 KB (gzipped) |
| Auto-reconnect | Manual implementation | Built-in with backoff |
| Rooms/namespaces | Manual implementation | Built-in |
| Fallback transport | None | HTTP long-polling |
| Event naming | Single "message" event | Custom event names |
| Binary support | Native | Auto-detected |
| Interop | Standard protocol | Socket.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 CWebSocket Close Codes
| Code | Name | Meaning |
|---|---|---|
| 1000 | Normal Closure | Connection fulfilled its purpose |
| 1001 | Going Away | Server shutting down or page navigating |
| 1006 | Abnormal Closure | Connection lost without close frame |
| 1008 | Policy Violation | Message violates server policy |
| 1011 | Internal Error | Unexpected server error |
| 4000-4999 | Application-defined | Custom 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 FormatterRelated Articles
GraphQL vs REST API
API architecture comparison including real-time subscriptions.
JWT vs Session Cookies
Authentication strategies for real-time applications.
HTTP Status Codes Guide
Understanding the HTTP upgrade process and status codes.
Docker for Developers
Containerize WebSocket servers with Docker Compose.