Building Scalable WebSocket Applications
Patterns and practices for building real-time applications with WebSockets at scale.
Harshit Shrivastav
Contributor
Building Scalable WebSocket Applications
Real-time features are expected in modern applications. Here's how to build WebSocket systems that scale.
Why WebSockets?
Traditional HTTP:
- Client polls server for updates
- High latency
- Wasteful bandwidth
WebSockets:
- Persistent bi-directional connection
- Real-time updates
- Efficient communication
Basic WebSocket Server
Node.js with ws
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('Client connected');
ws.on('message', (message) => {
console.log('Received:', message);
ws.send(`Echo: ${message}`);
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
Client-side
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected');
ws.send('Hello Server!');
};
ws.onmessage = (event) => {
console.log('Received:', event.data);
};
ws.onclose = () => {
console.log('Disconnected');
};
ws.onerror = (error) => {
console.error('Error:', error);
};
Scalability Challenges
Problem 1: Multiple Server Instances
With load balancing, users connect to different servers:
User A โ Server 1
User B โ Server 2
User A sends message โ How does User B receive it?
Solution: Redis Pub/Sub
const Redis = require('ioredis');
const publisher = new Redis();
const subscriber = new Redis();
// Subscribe to messages
subscriber.subscribe('chat');
subscriber.on('message', (channel, message) => {
// Broadcast to local WebSocket clients
broadcastToLocalClients(message);
});
// When receiving message from client
ws.on('message', (message) => {
// Publish to all servers
publisher.publish('chat', message);
});
Problem 2: Connection Management
Track which users are connected:
const connections = new Map();
wss.on('connection', (ws, req) => {
const userId = authenticateUser(req);
connections.set(userId, ws);
ws.on('close', () => {
connections.delete(userId);
});
});
// Send to specific user
function sendToUser(userId, message) {
const ws = connections.get(userId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
Production Patterns
1. Authentication
const jwt = require('jsonwebtoken');
wss.on('connection', (ws, req) => {
const token = req.url.split('token=')[1];
try {
const user = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = user.id;
} catch (err) {
ws.close(4001, 'Unauthorized');
return;
}
// Connection is authenticated
});
2. Heartbeat/Ping-Pong
Keep connections alive:
function heartbeat() {
this.isAlive = true;
}
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
// Check every 30 seconds
setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
3. Message Queuing
Handle bursts of messages:
const queue = [];
let processing = false;
ws.on('message', (message) => {
queue.push(message);
processQueue();
});
async function processQueue() {
if (processing) return;
processing = true;
while (queue.length > 0) {
const message = queue.shift();
await handleMessage(message);
}
processing = false;
}
4. Room-based Broadcasting
const rooms = new Map();
function joinRoom(ws, roomId) {
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(ws);
ws.currentRoom = roomId;
}
function broadcastToRoom(roomId, message) {
const clients = rooms.get(roomId);
if (!clients) return;
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
ws.on('close', () => {
if (ws.currentRoom) {
rooms.get(ws.currentRoom).delete(ws);
}
});
Using Socket.io
Socket.io adds convenience features:
const io = require('socket.io')(3000);
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// Join room
socket.on('join', (room) => {
socket.join(room);
});
// Broadcast to room
socket.on('message', (room, msg) => {
io.to(room).emit('message', msg);
});
// Direct message
socket.on('private', (recipientId, msg) => {
io.to(recipientId).emit('message', msg);
});
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
Monitoring and Debugging
Connection Metrics
let connectionCount = 0;
let messageCount = 0;
wss.on('connection', (ws) => {
connectionCount++;
console.log(`Connections: ${connectionCount}`);
ws.on('message', () => {
messageCount++;
});
ws.on('close', () => {
connectionCount--;
});
});
// Report metrics
setInterval(() => {
console.log({
connections: connectionCount,
messages: messageCount,
memoryUsage: process.memoryUsage()
});
}, 60000);
Error Handling
ws.on('error', (error) => {
console.error('WebSocket error:', error);
// Don't crash the server
});
process.on('uncaughtException', (error) => {
console.error('Uncaught exception:', error);
// Log but don't crash
});
Load Testing
Use tools to test WebSocket performance:
npm install -g artillery
artillery quick --count 100 --num 50 ws://localhost:8080
Best Practices
- Always authenticate WebSocket connections
- Implement heartbeats to detect dead connections
- Use Redis for multi-server setups
- Rate limit messages per connection
- Validate all incoming messages
- Monitor connection counts and message rates
- Implement graceful shutdown
- Use binary frames for large payloads
- Add reconnection logic on client
- Log errors, don't crash
Conclusion
Building scalable WebSocket applications requires:
- Proper connection management
- Redis for cross-server communication
- Heartbeat mechanisms
- Error handling
- Monitoring
Start simple, add complexity as you scale. Real-time doesn't have to be complicated.
Comments (0)
Loading comments...