Building Real-Time Applications with WebSockets
Real-time features — live chat, collaborative editing, real-time dashboards, stock tickers — require a persistent connection between client and server. HTTP’s request-response model wasn’t designed for this. WebSockets provide a full-duplex, persistent connection that lets both sides send messages at any time.
How WebSockets Work
- The client sends an HTTP request with an
Upgrade: websocketheader - The server responds with
101 Switching Protocols - The TCP connection stays open
- Both sides can send messages (frames) at any time until one side closes the connection
This upgrade happens once. After that, there’s no HTTP overhead — just lightweight binary or text frames.
Building a WebSocket Server with Node.js
Using the ws library:
npm install ws
const WebSocket = require('ws');
const server = new WebSocket.Server({ port: 8080 });
const clients = new Map();
server.on('connection', (ws, req) => {
const id = generateId();
clients.set(id, ws);
ws.on('message', (data) => {
const message = JSON.parse(data);
switch (message.type) {
case 'chat':
// Broadcast to all connected clients
broadcast({
type: 'chat',
from: id,
text: message.text,
timestamp: Date.now(),
});
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
}
});
ws.on('close', () => {
clients.delete(id);
broadcast({ type: 'user_left', userId: id });
});
// Send welcome message
ws.send(
JSON.stringify({
type: 'connected',
userId: id,
userCount: clients.size,
}),
);
// Notify others
broadcast({ type: 'user_joined', userId: id });
});
function broadcast(message) {
const data = JSON.stringify(message);
for (const [id, client] of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
function generateId() {
return Math.random().toString(36).substring(2, 9);
}
The Client Side
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected to server');
ws.send(JSON.stringify({ type: 'chat', text: 'Hello, world!' }));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
switch (message.type) {
case 'connected':
console.log(
`My ID: ${message.userId}, ${message.userCount} users online`,
);
break;
case 'chat':
displayChatMessage(message);
break;
case 'user_joined':
console.log(`User ${message.userId} joined`);
break;
}
};
ws.onclose = () => {
console.log('Disconnected');
// Reconnect after a delay
setTimeout(() => connectWebSocket(), 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
Handling Reconnection
Network connections are unreliable. Your client needs reconnection logic:
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
console.log('Connected');
};
this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
30000,
);
this.reconnectAttempts++;
setTimeout(() => this.connect(), delay);
}
};
this.ws.onmessage = (event) => {
if (this.onMessage) this.onMessage(JSON.parse(event.data));
};
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
Scaling WebSockets
A single server can handle thousands of WebSocket connections, but for real production workloads, you’ll need multiple servers. The challenge: a client connected to Server A sends a message that needs to reach a client connected to Server B.
Solution: Redis Pub/Sub
Each server instance subscribes to a Redis channel. When a server receives a message, it publishes it to Redis. All servers receive it and forward it to their connected clients:
const redis = require('redis');
const subscriber = redis.createClient();
const publisher = redis.createClient();
subscriber.subscribe('ws:broadcast');
subscriber.on('message', (channel, message) => {
// Forward to local clients
const data = JSON.stringify(JSON.parse(message));
for (const client of localClients.values()) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
});
// When receiving from a client
ws.on('message', (data) => {
publisher.publish('ws:broadcast', data);
});
Message Ordering
With pub/sub across multiple servers, message ordering can be tricky. Strategies:
- Include a server timestamp and sequence number in each message
- Use a single writer pattern — one server handles all writes, others proxy
- Accept eventual ordering and handle it on the client side
Heartbeats and Ping/Pong
WebSocket connections can appear open but actually be dead (half-open connections). Implement heartbeats:
// Server-side heartbeat
const interval = setInterval(() => {
server.clients.forEach((ws) => {
if (!ws.isAlive) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
server.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
Security Considerations
- Authenticate on upgrade: Validate the session token during the HTTP upgrade handshake
- Validate messages: Never trust client-sent data — validate type, structure, and content
- Rate limit: Prevent clients from flooding the server with messages
- Use
wss://: Always use TLS in production (WebSocket Secure) - Origin checking: Verify the
Originheader during the handshake to prevent cross-site WebSocket hijacking
server.on('connection', (ws, req) => {
const origin = req.headers.origin;
if (origin !== 'https://yourdomain.com') {
ws.close(4001, 'Unauthorized origin');
return;
}
});
When to Use WebSockets
- Chat and messaging: Natural fit — bidirectional, low latency
- Collaborative editing: Multiple users editing simultaneously
- Live dashboards: Real-time metrics and monitoring
- Gaming: Multi-player game state synchronization
- Live notifications: Push updates to clients without polling
When Not to Use WebSockets
- Occasional updates: If you only need to push updates every few minutes, Server-Sent Events (SSE) or polling is simpler
- Request-response patterns: If the client always initiates, stick with HTTP
- Unidirectional data: If the server only pushes to clients, SSE is a lighter-weight option
WebSockets are powerful but come with real complexity — reconnection, scaling, ordering, and operational overhead. Use them when you genuinely need bidirectional, low-latency communication. For simpler needs, simpler solutions exist.