Skip to main content

WebSockets and Real-Time Communication

Real-time communication enables servers to push data to clients immediately when events occur, rather than clients repeatedly polling for updates. This creates responsive user experiences for features like live notifications, collaborative editing, chat systems, and live dashboards. The two primary technologies for real-time communication in web applications are WebSockets (bidirectional) and Server-Sent Events (unidirectional).

Core Principles

  • Connection lifecycle management - Handle connection establishment, maintenance, reconnection, and graceful shutdown
  • Scalability - Design for horizontal scaling with load balancing and message distribution across server instances
  • Resilience - Implement reconnection strategies, message queuing, and graceful degradation
  • Security - Authenticate and authorize WebSocket connections, validate messages, and protect against abuse
  • Performance - Minimize message overhead, implement throttling, and optimize message serialization

WebSocket Protocol and Lifecycle

WebSockets provide full-duplex communication channels over a single TCP connection. Unlike HTTP request/response, WebSockets maintain a persistent connection that allows both client and server to send messages at any time.

WebSocket Handshake

WebSocket connections begin with an HTTP upgrade request. The client sends a standard HTTP request with specific headers indicating it wants to upgrade to WebSocket:

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server supports WebSockets, it responds with HTTP 101 Switching Protocols:

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

After this handshake, the HTTP connection is replaced with the WebSocket protocol. The connection remains open for bidirectional communication until explicitly closed by either party.

The Sec-WebSocket-Key and Sec-WebSocket-Accept headers prevent accidental WebSocket connections from HTTP clients that don't understand WebSockets but do not provide authentication or authorization - implement these separately.

WebSocket Frame Structure

After the handshake, data is transmitted in frames. WebSocket frames are lightweight binary structures with minimal overhead:

      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 ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+

Frame types include:

  • Text frames (opcode 0x1): UTF-8 encoded text messages
  • Binary frames (opcode 0x2): Binary data
  • Ping frames (opcode 0x9): Keepalive from client or server
  • Pong frames (opcode 0xA): Response to ping
  • Close frames (opcode 0x8): Connection termination

Connection Lifecycle

A complete WebSocket lifecycle includes connection, communication, and termination:

// Client-side WebSocket lifecycle
const ws = new WebSocket('ws://localhost:3000/ws');

// Connection opened
ws.addEventListener('open', (event) => {
console.log('WebSocket connected');
ws.send(JSON.stringify({ type: 'subscribe', channel: 'notifications' }));
});

// Message received
ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
});

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

// Connection closed
ws.addEventListener('close', (event) => {
console.log('WebSocket closed:', event.code, event.reason);

// Attempt reconnection if not a normal closure
if (event.code !== 1000) {
setTimeout(() => reconnect(), 1000);
}
});

Server-side implementation using the ws library:

import { WebSocketServer } from 'ws';

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

wss.on('connection', (ws, req) => {
console.log('Client connected:', req.socket.remoteAddress);

// Send welcome message
ws.send(JSON.stringify({ type: 'welcome', message: 'Connected to server' }));

// Handle incoming messages
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString());
handleMessage(ws, message);
} catch (error) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
}
});

// Handle ping/pong for keepalive
ws.on('ping', () => {
ws.pong();
});

// Handle errors
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});

// Handle disconnection
ws.on('close', (code, reason) => {
console.log('Client disconnected:', code, reason.toString());
cleanup(ws);
});
});

Ping/Pong Heartbeat

WebSocket connections can silently fail due to network issues, proxy timeouts, or server crashes. Implement heartbeat mechanisms to detect dead connections:

Server-initiated heartbeat:

const HEARTBEAT_INTERVAL = 30000; // 30 seconds
const HEARTBEAT_TIMEOUT = 35000; // 35 seconds

function setupHeartbeat(ws) {
let isAlive = true;

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

const interval = setInterval(() => {
if (!isAlive) {
console.log('Client did not respond to ping, terminating');
ws.terminate();
clearInterval(interval);
return;
}

isAlive = false;
ws.ping();
}, HEARTBEAT_INTERVAL);

ws.on('close', () => {
clearInterval(interval);
});
}

wss.on('connection', (ws) => {
setupHeartbeat(ws);
});

Client-initiated heartbeat:

class ReliableWebSocket {
private ws: WebSocket;
private pingInterval: NodeJS.Timeout;
private pongTimeout: NodeJS.Timeout;

connect() {
this.ws = new WebSocket('ws://localhost:3000/ws');

this.ws.addEventListener('open', () => {
this.startHeartbeat();
});

this.ws.addEventListener('close', () => {
this.stopHeartbeat();
});
}

private startHeartbeat() {
this.pingInterval = setInterval(() => {
// Send ping message (application-level, not WebSocket ping frame)
this.ws.send(JSON.stringify({ type: 'ping' }));

// Expect pong within timeout
this.pongTimeout = setTimeout(() => {
console.log('No pong received, reconnecting');
this.ws.close();
this.reconnect();
}, 5000);
}, 30000);

// Handle pong response
this.ws.addEventListener('message', (event) => {
const message = JSON.parse(event.data);
if (message.type === 'pong') {
clearTimeout(this.pongTimeout);
}
});
}

private stopHeartbeat() {
clearInterval(this.pingInterval);
clearTimeout(this.pongTimeout);
}
}

Heartbeats serve two purposes: detecting dead connections and preventing idle timeout from intermediary proxies or load balancers.

Server-Sent Events (SSE)

Server-Sent Events provide unidirectional server-to-client communication over HTTP. Unlike WebSockets, SSE is simpler, uses standard HTTP, and automatically reconnects when the connection drops.

SSE Protocol

SSE uses a persistent HTTP connection with Content-Type: text/event-stream. The server sends events in a text-based format:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

event: message
data: {"type": "notification", "content": "New message"}
id: 1

event: update
data: {"type": "status", "status": "online"}
id: 2

data: This is a message without an event type
id: 3

Each event consists of:

  • event: Optional event type (defaults to "message")
  • data: The payload (can span multiple lines)
  • id: Optional event ID for reconnection
  • retry: Optional reconnection delay in milliseconds

Events are separated by blank lines (\n\n).

Server Implementation

Implementing SSE on the server is straightforward - keep the response open and write events:

import express from 'express';

const app = express();

app.get('/events', (req, res) => {
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// Send initial connection success event
res.write('event: connected\n');
res.write('data: {"message": "Connected to event stream"}\n\n');

// Store client connection
const clientId = Date.now();
clients.set(clientId, res);

// Send periodic heartbeat to keep connection alive
const heartbeat = setInterval(() => {
res.write(':heartbeat\n\n'); // Comment line (starts with :)
}, 30000);

// Clean up when client disconnects
req.on('close', () => {
clearInterval(heartbeat);
clients.delete(clientId);
console.log(`Client ${clientId} disconnected`);
});
});

// Function to broadcast events to all connected clients
function broadcast(event: string, data: any) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;

clients.forEach((client) => {
client.write(payload);
});
}

// Example: Send event when something happens
app.post('/notifications', (req, res) => {
const notification = req.body;

// Broadcast to all SSE clients
broadcast('notification', notification);

res.json({ success: true });
});

Client Implementation

The browser's EventSource API provides built-in SSE support:

const eventSource = new EventSource('/events');

// Handle default "message" events
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Received message:', data);
});

// Handle custom event types
eventSource.addEventListener('notification', (event) => {
const notification = JSON.parse(event.data);
showNotification(notification);
});

eventSource.addEventListener('update', (event) => {
const update = JSON.parse(event.data);
updateUI(update);
});

// Handle connection open
eventSource.addEventListener('open', () => {
console.log('SSE connection established');
});

// Handle errors and reconnection
eventSource.addEventListener('error', (event) => {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('SSE connection closed');
} else {
console.log('SSE error, will auto-reconnect');
}
});

Automatic reconnection: EventSource automatically reconnects when the connection drops. The browser sends a Last-Event-ID header with the ID of the last received event, allowing the server to resume from that point:

app.get('/events', (req, res) => {
const lastEventId = req.headers['last-event-id'];

// If client is reconnecting, send missed events
if (lastEventId) {
const missedEvents = getEventsSince(lastEventId);
missedEvents.forEach(event => {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify(event.data)}\n`);
res.write(`id: ${event.id}\n\n`);
});
}

// Continue with normal event stream
// ...
});

SSE Limitations

Unidirectional: SSE only supports server-to-client messages. For client-to-server communication, use standard HTTP requests.

Browser connection limits: Browsers limit the number of concurrent HTTP connections per domain (typically 6). SSE connections count toward this limit. This can be mitigated by using HTTP/2, which allows unlimited concurrent requests over a single connection.

IE/Edge support: Older browsers don't support SSE. For broad browser support, use WebSockets or polyfills.

Socket.io vs Native WebSockets

Socket.io is a library built on top of WebSockets that provides fallback mechanisms, automatic reconnection, and additional features. Understanding the trade-offs helps you choose the right tool.

Native WebSockets

Advantages:

  • No dependencies, lightweight
  • Standard protocol, works with any WebSocket server
  • Direct control over connection and messages
  • Lower overhead (no Socket.io protocol layer)

Implementation:

// Server (using ws library)
import { WebSocketServer } from 'ws';

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

wss.on('connection', (ws) => {
ws.on('message', (data) => {
// Broadcast to all clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
});
});

// Client
const ws = new WebSocket('ws://localhost:3000');
ws.addEventListener('message', (event) => {
console.log('Message:', event.data);
});
ws.send('Hello server');

Socket.io

Advantages:

  • Automatic reconnection with exponential backoff
  • Fallback to HTTP long polling if WebSockets unavailable
  • Built-in event system (named events instead of raw messages)
  • Room and namespace support for message routing
  • Acknowledgments (request/response pattern over WebSockets)

Implementation:

// Server
import { Server } from 'socket.io';

const io = new Server(3000);

io.on('connection', (socket) => {
console.log('Client connected:', socket.id);

// Handle named events
socket.on('message', (data, callback) => {
console.log('Received:', data);

// Broadcast to all clients in a room
io.to('notifications').emit('update', data);

// Send acknowledgment back to sender
callback({ status: 'received' });
});

// Join a room
socket.on('subscribe', (room) => {
socket.join(room);
});

socket.on('disconnect', () => {
console.log('Client disconnected:', socket.id);
});
});

// Client
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity
});

socket.on('connect', () => {
console.log('Connected:', socket.id);
socket.emit('subscribe', 'notifications');
});

// Named events
socket.on('update', (data) => {
console.log('Update received:', data);
});

// Acknowledgments (request/response)
socket.emit('message', { text: 'Hello' }, (response) => {
console.log('Server acknowledged:', response);
});

socket.on('disconnect', () => {
console.log('Disconnected, will auto-reconnect');
});

Rooms and Namespaces

Socket.io's rooms and namespaces simplify message routing:

Rooms group sockets for targeted broadcasting:

// Server
io.on('connection', (socket) => {
// Join user to their personal room
socket.join(`user-${socket.userId}`);

// Send notification to specific user
io.to(`user-${targetUserId}`).emit('notification', message);

// Broadcast to all users in a chat room
io.to(`chat-${roomId}`).emit('message', chatMessage);
});

Namespaces provide logical separation of concerns:

// Server - separate namespaces for different features
const chatNamespace = io.of('/chat');
const notificationNamespace = io.of('/notifications');

chatNamespace.on('connection', (socket) => {
socket.on('message', (msg) => {
chatNamespace.emit('message', msg);
});
});

notificationNamespace.on('connection', (socket) => {
socket.on('subscribe', (userId) => {
socket.join(`user-${userId}`);
});
});

// Client - connect to specific namespace
const chatSocket = io('http://localhost:3000/chat');
const notificationSocket = io('http://localhost:3000/notifications');

When to Choose Each

Use native WebSockets when:

  • You need minimal overhead and dependencies
  • You have full control over both client and server
  • Your server is written in a language without Socket.io support
  • You need to integrate with existing WebSocket infrastructure

Use Socket.io when:

  • You need automatic reconnection and fallback mechanisms
  • You want built-in room and namespace support
  • You need request/response patterns over WebSockets (acknowledgments)
  • You're building a JavaScript/TypeScript application on both ends

Connection Management and Reconnection

Connections drop due to network interruptions, server restarts, load balancer timeouts, and mobile devices switching networks. Robust reconnection strategies ensure seamless user experience.

Exponential Backoff

Reconnection attempts should use exponential backoff to avoid overwhelming the server during outages:

class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private baseDelay = 1000; // 1 second
private maxDelay = 30000; // 30 seconds

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

this.ws.addEventListener('open', () => {
console.log('Connected');
this.reconnectAttempts = 0; // Reset on successful connection
this.onOpen?.();
});

this.ws.addEventListener('close', (event) => {
console.log('Disconnected:', event.code, event.reason);

// Don't reconnect if closed intentionally (code 1000)
if (event.code === 1000) {
return;
}

this.reconnect();
});

this.ws.addEventListener('error', (error) => {
console.error('WebSocket error:', error);
});
}

private reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
this.onMaxReconnectAttemptsReached?.();
return;
}

this.reconnectAttempts++;

// Calculate delay with exponential backoff and jitter
const exponentialDelay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts),
this.maxDelay
);

// Add jitter (±25%) to prevent thundering herd
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5);
const delay = exponentialDelay + jitter;

console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);

setTimeout(() => {
this.connect();
}, delay);
}

send(data: string) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
} else {
console.warn('Cannot send, not connected');
// Optionally queue messages for sending after reconnection
this.messageQueue.push(data);
}
}

close() {
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
this.ws?.close(1000, 'Client closing connection');
}
}

The exponential backoff algorithm increases delay between reconnection attempts: 1s, 2s, 4s, 8s, 16s, 30s (capped). Jitter (random variation) prevents all disconnected clients from reconnecting simultaneously (thundering herd problem).

Connection State Management

Track connection state to provide appropriate UI feedback:

enum ConnectionState {
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
DISCONNECTED = 'disconnected',
FAILED = 'failed'
}

class WebSocketManager {
private state: ConnectionState = ConnectionState.DISCONNECTED;
private stateListeners: ((state: ConnectionState) => void)[] = [];

private setState(newState: ConnectionState) {
this.state = newState;
this.stateListeners.forEach(listener => listener(newState));
}

onStateChange(listener: (state: ConnectionState) => void) {
this.stateListeners.push(listener);
return () => {
this.stateListeners = this.stateListeners.filter(l => l !== listener);
};
}

connect() {
this.setState(ConnectionState.CONNECTING);

this.ws = new WebSocket(this.url);

this.ws.addEventListener('open', () => {
this.setState(ConnectionState.CONNECTED);
});

this.ws.addEventListener('close', () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.setState(ConnectionState.RECONNECTING);
this.reconnect();
} else {
this.setState(ConnectionState.FAILED);
}
});
}
}

// UI integration
const wsManager = new WebSocketManager('ws://localhost:3000');

wsManager.onStateChange((state) => {
switch (state) {
case ConnectionState.CONNECTING:
showStatus('Connecting...');
break;
case ConnectionState.CONNECTED:
showStatus('Connected');
hideReconnectBanner();
break;
case ConnectionState.RECONNECTING:
showReconnectBanner('Reconnecting...');
break;
case ConnectionState.FAILED:
showError('Connection failed. Please refresh the page.');
break;
}
});

Message Queuing

Queue messages sent while disconnected and send them after reconnection:

class QueueingWebSocket {
private messageQueue: string[] = [];
private maxQueueSize = 100;

send(data: string) {
if (this.ws?.readyState === WebSocket.OPEN) {
// Send immediately if connected
this.ws.send(data);
} else {
// Queue for later if disconnected
if (this.messageQueue.length >= this.maxQueueSize) {
console.warn('Message queue full, dropping oldest message');
this.messageQueue.shift();
}
this.messageQueue.push(data);
}
}

private onOpen() {
// Send queued messages after reconnection
while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
const message = this.messageQueue.shift()!;
this.ws.send(message);
}
}
}

Trade-off: Message queuing ensures no data loss but can overwhelm the server with a burst of messages after reconnection. Consider rate limiting queued message delivery or implementing server-side deduplication.

Scaling WebSocket Connections

WebSocket connections are stateful and long-lived, which creates challenges for horizontal scaling. Traditional load balancing strategies don't work well because connections must consistently route to the same server instance.

Sticky Sessions

Sticky sessions (session affinity) ensure requests from the same client always route to the same server instance:

# Nginx configuration with sticky sessions
upstream websocket_backend {
ip_hash; # Route based on client IP
server backend1.example.com:3000;
server backend2.example.com:3000;
server backend3.example.com:3000;
}

server {
listen 80;

location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

# Prevent timeout
proxy_read_timeout 86400; # 24 hours
}
}

Limitation: Sticky sessions prevent even load distribution. If one server handles many active connections, it becomes overloaded while others are underutilized. Also, if a server crashes, all its clients must reconnect and establish new sessions.

Redis Pub/Sub for Multi-Server Communication

Use Redis pub/sub to broadcast messages across all server instances. When any instance receives a message that should be broadcast, it publishes to Redis. All instances subscribe to Redis and forward messages to their connected clients:

import { createClient } from 'redis';
import { WebSocketServer } from 'ws';

const redisPublisher = createClient();
const redisSubscriber = createClient();

await redisPublisher.connect();
await redisSubscriber.connect();

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

// Map of client ID to WebSocket connection
const clients = new Map<string, WebSocket>();

// Subscribe to Redis channels
await redisSubscriber.subscribe('notifications', (message) => {
const data = JSON.parse(message);

// Forward to specific client if targeted
if (data.userId) {
const client = clients.get(data.userId);
if (client?.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data.payload));
}
} else {
// Broadcast to all clients on this server instance
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data.payload));
}
});
}
});

wss.on('connection', (ws, req) => {
const userId = authenticateUser(req);
clients.set(userId, ws);

ws.on('message', async (data) => {
const message = JSON.parse(data.toString());

// Publish to Redis so all server instances receive it
await redisPublisher.publish('notifications', JSON.stringify({
userId: message.targetUserId, // Target specific user or null for broadcast
payload: message
}));
});

ws.on('close', () => {
clients.delete(userId);
});
});

This architecture allows horizontal scaling: add more server instances and they automatically participate in message distribution through Redis.

Connection Pooling

Limit the number of connections per server instance to prevent resource exhaustion:

const MAX_CONNECTIONS = 10000;
let currentConnections = 0;

wss.on('connection', (ws, req) => {
if (currentConnections >= MAX_CONNECTIONS) {
ws.close(1008, 'Server at capacity');
return;
}

currentConnections++;

ws.on('close', () => {
currentConnections--;
});
});

Monitor connection counts and add server instances when approaching limits. For more on performance monitoring, see Metrics and Monitoring and Alerting.

Authentication and Authorization

WebSocket connections must be authenticated and authorized to prevent unauthorized access and data leakage.

Token-Based Authentication

Pass authentication tokens during the WebSocket handshake. Tokens can be included in:

Query parameters:

// Client
const token = localStorage.getItem('auth_token');
const ws = new WebSocket(`ws://localhost:3000/ws?token=${token}`);

// Server
import { parse } from 'url';
import { verify } from 'jsonwebtoken';

wss.on('connection', (ws, req) => {
const { query } = parse(req.url!, true);
const token = query.token as string;

if (!token) {
ws.close(1008, 'Authentication required');
return;
}

try {
const decoded = verify(token, process.env.JWT_SECRET!);
ws.userId = decoded.userId; // Attach user info to connection
} catch (error) {
ws.close(1008, 'Invalid token');
return;
}

// Connection authenticated, proceed
});

Custom headers (requires library support, not available in browser WebSocket API):

// Using Socket.io client
const socket = io('http://localhost:3000', {
auth: {
token: localStorage.getItem('auth_token')
}
});

// Server
io.use((socket, next) => {
const token = socket.handshake.auth.token;

if (!token) {
return next(new Error('Authentication required'));
}

try {
const decoded = verify(token, process.env.JWT_SECRET!);
socket.userId = decoded.userId;
next();
} catch (error) {
next(new Error('Invalid token'));
}
});

Initial message (authenticate after connection):

// Client
const ws = new WebSocket('ws://localhost:3000/ws');

ws.addEventListener('open', () => {
ws.send(JSON.stringify({
type: 'auth',
token: localStorage.getItem('auth_token')
}));
});

// Server
wss.on('connection', (ws) => {
let authenticated = false;

const authTimeout = setTimeout(() => {
if (!authenticated) {
ws.close(1008, 'Authentication timeout');
}
}, 5000);

ws.on('message', (data) => {
const message = JSON.parse(data.toString());

if (!authenticated) {
if (message.type === 'auth') {
try {
const decoded = verify(message.token, process.env.JWT_SECRET!);
ws.userId = decoded.userId;
authenticated = true;
clearTimeout(authTimeout);
ws.send(JSON.stringify({ type: 'auth_success' }));
} catch (error) {
ws.close(1008, 'Invalid token');
}
} else {
ws.close(1008, 'Authentication required');
}
return;
}

// Handle authenticated messages
handleMessage(ws, message);
});
});

For more authentication strategies, see Authentication.

Message Authorization

Authenticate the connection, but authorize individual messages. Users might be authenticated but not authorized to access specific resources:

ws.on('message', async (data) => {
const message = JSON.parse(data.toString());

if (message.type === 'subscribe') {
// Check if user can subscribe to this channel
const canAccess = await checkPermission(ws.userId, message.channel);

if (!canAccess) {
ws.send(JSON.stringify({
type: 'error',
message: 'Access denied to channel',
channel: message.channel
}));
return;
}

// Subscribe user to channel
subscribeToChannel(ws, message.channel);
}

if (message.type === 'message') {
// Check if user can send messages to this channel
const canSend = await checkSendPermission(ws.userId, message.channel);

if (!canSend) {
ws.send(JSON.stringify({
type: 'error',
message: 'Cannot send to channel'
}));
return;
}

// Broadcast message
broadcastToChannel(message.channel, message.content);
}
});

For more authorization patterns, see Authorization.

Session Validation

Periodically revalidate tokens to detect revoked sessions:

const SESSION_VALIDATION_INTERVAL = 5 * 60 * 1000; // 5 minutes

wss.on('connection', (ws) => {
const validationInterval = setInterval(async () => {
const isValid = await validateSession(ws.userId);

if (!isValid) {
ws.close(1008, 'Session expired');
clearInterval(validationInterval);
}
}, SESSION_VALIDATION_INTERVAL);

ws.on('close', () => {
clearInterval(validationInterval);
});
});

Message Delivery Guarantees

Different applications require different delivery guarantees. Understanding the trade-offs helps you choose the right approach.

At-Most-Once Delivery

The default WebSocket behavior provides at-most-once delivery. Messages are sent once and not retried. If the connection drops, in-flight messages are lost.

When to use: Appropriate for real-time updates where losing occasional messages is acceptable (e.g., live sports scores, stock prices, mouse positions in collaborative tools).

At-Least-Once Delivery

Ensure every message is delivered by implementing acknowledgments and retries:

// Client
class ReliableWebSocket {
private pendingMessages = new Map<string, { data: string; retries: number }>();
private messageId = 0;

send(data: any) {
const id = `msg-${this.messageId++}`;
const message = { id, data };

this.pendingMessages.set(id, { data: JSON.stringify(message), retries: 0 });
this.ws.send(JSON.stringify(message));

// Retry if no acknowledgment within timeout
setTimeout(() => this.checkAcknowledgment(id), 5000);
}

private checkAcknowledgment(id: string) {
const pending = this.pendingMessages.get(id);

if (!pending) {
return; // Already acknowledged
}

if (pending.retries >= 3) {
console.error('Message failed after 3 retries:', id);
this.pendingMessages.delete(id);
return;
}

// Resend
pending.retries++;
this.ws.send(pending.data);
setTimeout(() => this.checkAcknowledgment(id), 5000);
}

private handleMessage(event: MessageEvent) {
const message = JSON.parse(event.data);

if (message.type === 'ack') {
this.pendingMessages.delete(message.id);
} else {
// Handle regular message
this.onMessage(message);

// Send acknowledgment
this.ws.send(JSON.stringify({ type: 'ack', id: message.id }));
}
}
}

Server-side:

const processedMessages = new Set<string>();

ws.on('message', (data) => {
const message = JSON.parse(data.toString());

if (message.type === 'ack') {
// Client acknowledged our message
handleAcknowledgment(message.id);
return;
}

// Check for duplicate (message was retried)
if (processedMessages.has(message.id)) {
// Already processed, but send ack again
ws.send(JSON.stringify({ type: 'ack', id: message.id }));
return;
}

// Process message
processMessage(message);
processedMessages.add(message.id);

// Send acknowledgment
ws.send(JSON.stringify({ type: 'ack', id: message.id }));
});

Trade-off: At-least-once delivery ensures messages arrive but may deliver duplicates. The receiver must handle idempotency.

Exactly-Once Delivery

Exactly-once delivery is theoretically impossible in distributed systems, but can be approximated through idempotent message processing. Instead of preventing duplicates, design message handlers to produce the same result regardless of how many times they're called:

// Idempotent message handler
async function handleTransferFunds(message: TransferMessage) {
// Use message ID as idempotency key
const existingTransfer = await db.transfers.findUnique({
where: { idempotencyKey: message.id }
});

if (existingTransfer) {
// Already processed
return existingTransfer;
}

// Process transfer with idempotency key
const transfer = await db.transfers.create({
data: {
idempotencyKey: message.id,
from: message.fromAccount,
to: message.toAccount,
amount: message.amount
}
});

return transfer;
}

By using the message ID as an idempotency key, duplicate messages don't cause duplicate transfers.

Performance Optimization

Message Serialization

JSON is convenient but not the most efficient serialization format. For high-throughput applications, consider binary formats:

MessagePack (binary JSON-like format):

import * as msgpack from 'msgpack-lite';

// Encode
const buffer = msgpack.encode({ type: 'update', data: { value: 42 } });
ws.send(buffer);

// Decode
ws.addEventListener('message', (event) => {
const message = msgpack.decode(new Uint8Array(event.data));
});

Protocol Buffers (efficient, strongly typed):

// message.proto
syntax = "proto3";

message Update {
string type = 1;
int32 value = 2;
string userId = 3;
}
import { Update } from './generated/message_pb';

// Encode
const update = new Update();
update.setType('update');
update.setValue(42);
update.setUserId('user-123');

ws.send(update.serializeBinary());

// Decode
ws.addEventListener('message', (event) => {
const update = Update.deserializeBinary(new Uint8Array(event.data));
console.log(update.getValue());
});

Binary formats reduce message size (typically 20-50% smaller than JSON) and improve serialization/deserialization performance.

Message Throttling

Prevent overwhelming clients with too many messages:

class ThrottledBroadcaster {
private lastBroadcast = Date.now();
private pendingUpdate: any = null;
private throttleMs = 100; // Maximum 10 updates per second

broadcast(data: any) {
this.pendingUpdate = data;

const now = Date.now();
const timeSinceLastBroadcast = now - this.lastBroadcast;

if (timeSinceLastBroadcast >= this.throttleMs) {
this.flush();
} else {
// Schedule flush
setTimeout(() => this.flush(), this.throttleMs - timeSinceLastBroadcast);
}
}

private flush() {
if (!this.pendingUpdate) {
return;
}

clients.forEach(client => {
client.send(JSON.stringify(this.pendingUpdate));
});

this.lastBroadcast = Date.now();
this.pendingUpdate = null;
}
}

Rapidly changing data like mouse positions, scroll positions, or live metrics overwhelms clients and networks without throttling, degrading performance and user experience.

Message Compression

Enable WebSocket compression (permessage-deflate) to reduce bandwidth:

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({
port: 3000,
perMessageDeflate: {
zlibDeflateOptions: {
level: 6 // Compression level 1-9
},
threshold: 1024 // Only compress messages > 1KB
}
});

Trade-off: Compression reduces bandwidth but increases CPU usage. Only compress messages above a threshold (typically 1KB) to avoid wasting CPU on small messages.

For more performance optimization strategies, see Performance Optimization.

Use Cases and Patterns

Real-Time Notifications

Push notifications to users when events occur:

// Server-side notification system
class NotificationService {
private userConnections = new Map<string, Set<WebSocket>>();

registerConnection(userId: string, ws: WebSocket) {
if (!this.userConnections.has(userId)) {
this.userConnections.set(userId, new Set());
}
this.userConnections.get(userId)!.add(ws);

ws.on('close', () => {
this.userConnections.get(userId)?.delete(ws);
});
}

async sendNotification(userId: string, notification: Notification) {
const connections = this.userConnections.get(userId);

if (connections) {
// User is online, send via WebSocket
connections.forEach(ws => {
ws.send(JSON.stringify({
type: 'notification',
data: notification
}));
});
} else {
// User is offline, store for later
await db.notifications.create({
data: { userId, ...notification, read: false }
});
}
}
}

// When user connects, send unread notifications
wss.on('connection', async (ws, req) => {
const userId = authenticateUser(req);
notificationService.registerConnection(userId, ws);

// Send unread notifications
const unread = await db.notifications.findMany({
where: { userId, read: false }
});

unread.forEach(notification => {
ws.send(JSON.stringify({
type: 'notification',
data: notification
}));
});
});

Live Activity Feed

Stream updates to activity feeds in real-time:

// Activity subscription
ws.on('message', (data) => {
const message = JSON.parse(data.toString());

if (message.type === 'subscribe_feed') {
// Subscribe to user's personalized feed
const feedChannel = `feed:${ws.userId}`;
subscribeToChannel(ws, feedChannel);

// Also subscribe to followed users' activities
const followedUsers = await db.follows.findMany({
where: { followerId: ws.userId }
});

followedUsers.forEach(follow => {
subscribeToChannel(ws, `user:${follow.followingId}`);
});
}
});

// When activity occurs, publish to relevant channels
async function publishActivity(userId: string, activity: Activity) {
// Publish to user's channel
await redisPublisher.publish(`user:${userId}`, JSON.stringify(activity));

// Publish to followers' feeds
const followers = await db.follows.findMany({
where: { followingId: userId }
});

await Promise.all(
followers.map(follower =>
redisPublisher.publish(`feed:${follower.followerId}`, JSON.stringify(activity))
)
);
}

Collaborative Editing

Enable real-time collaboration with operational transformation or CRDTs:

// Simplified collaborative editing (not production-ready)
const documentState = new Map<string, string>();
const documentSubscribers = new Map<string, Set<WebSocket>>();

ws.on('message', (data) => {
const message = JSON.parse(data.toString());

if (message.type === 'join_document') {
const docId = message.documentId;

if (!documentSubscribers.has(docId)) {
documentSubscribers.set(docId, new Set());
}
documentSubscribers.get(docId)!.add(ws);

// Send current document state
ws.send(JSON.stringify({
type: 'document_state',
content: documentState.get(docId) || ''
}));
}

if (message.type === 'edit') {
const docId = message.documentId;

// Apply edit (in production, use operational transformation)
const currentContent = documentState.get(docId) || '';
const newContent = applyEdit(currentContent, message.edit);
documentState.set(docId, newContent);

// Broadcast to all subscribers except sender
documentSubscribers.get(docId)?.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'edit',
edit: message.edit,
userId: ws.userId
}));
}
});
}
});

For production collaborative editing, use libraries like Yjs or Automerge that implement CRDTs (Conflict-free Replicated Data Types) for conflict resolution.

Live Dashboard Updates

Stream real-time metrics and status updates to dashboards:

// Metrics broadcaster
const metricsInterval = setInterval(async () => {
const metrics = await collectMetrics();

// Broadcast to all dashboard clients
dashboardClients.forEach(ws => {
ws.send(JSON.stringify({
type: 'metrics_update',
data: metrics,
timestamp: Date.now()
}));
});
}, 5000); // Update every 5 seconds

// Client-side dashboard
const socket = new WebSocket('ws://localhost:3000/dashboard');

socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data);

if (message.type === 'metrics_update') {
updateCharts(message.data);
updateLastUpdated(message.timestamp);
}
});