Building Real-Time Applications with WebSockets and Server-Sent Events
Real-time features have become expected in modern applications. Whether it's chat, live notifications, or collaborative editing, users expect instant updates. After building real-time features for multiple production applications, I've learned the patterns and pitfalls. Let me share what works.
Understanding Real-Time Communication
HTTP Limitations
Traditional HTTP is request-response:
- Client requests → Server responds → Connection closes
- No server-initiated communication
- Polling is inefficient
Real-Time Solutions
- WebSockets - Full-duplex communication
- Server-Sent Events (SSE) - Server-to-client streaming
- Long Polling - Extended HTTP requests
WebSockets
How WebSockets Work
- Client initiates HTTP upgrade request
- Server upgrades to WebSocket protocol
- Persistent bidirectional connection
- Both sides can send messages anytime
WebSocket Server (Node.js)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = getUserIdFromRequest(req);
clients.set(userId, ws);
console.log(`Client connected: ${userId}`);
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
handleMessage(ws, data);
} catch (error) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
clients.delete(userId);
console.log(`Client disconnected: ${userId}`);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(userId);
});
});
function handleMessage(ws, data) {
switch (data.type) {
case 'chat':
broadcastMessage(data);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
default:
ws.send(JSON.stringify({ error: 'Unknown message type' }));
}
}
function broadcastMessage(message) {
const data = JSON.stringify(message);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
}
WebSocket Client (Browser)
const ws = new WebSocket('ws://localhost:8080');
ws.onopen = () => {
console.log('Connected to server');
// Send authentication
ws.send(JSON.stringify({
type: 'auth',
token: getAuthToken()
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleServerMessage(data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected from server');
// Reconnect logic
setTimeout(connectWebSocket, 5000);
};
function sendMessage(text) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'chat',
message: text,
timestamp: Date.now()
}));
}
}
function handleServerMessage(data) {
switch (data.type) {
case 'chat':
displayMessage(data.message);
break;
case 'notification':
showNotification(data);
break;
case 'error':
handleError(data);
break;
}
}
Reconnection Strategy
class WebSocketManager {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.onOpen();
};
this.ws.onclose = () => {
console.log('Disconnected');
this.reconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onmessage = (event) => {
this.onMessage(JSON.parse(event.data));
};
}
reconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.connect(), delay);
} else {
console.error('Max reconnection attempts reached');
}
}
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
close() {
if (this.ws) {
this.ws.close();
}
}
}
Server-Sent Events (SSE)
When to Use SSE
- One-way communication - Server to client only
- Simple implementation - Easier than WebSockets
- Automatic reconnection - Browser handles it
- HTTP-based - Works through proxies/firewalls
SSE Server (Node.js)
const express = require('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');
res.setHeader('Access-Control-Allow-Origin', '*');
// Send initial connection message
res.write('data: {"type":"connected"}\n\n');
// Send periodic updates
const interval = setInterval(() => {
const data = {
type: 'update',
timestamp: Date.now(),
message: 'Server update'
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 5000);
// Clean up on client disconnect
req.on('close', () => {
clearInterval(interval);
res.end();
});
});
app.listen(3000);
SSE Client (Browser)
const eventSource = new EventSource('/events');
eventSource.onopen = () => {
console.log('SSE connection opened');
};
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Browser automatically reconnects
};
eventSource.addEventListener('custom-event', (event) => {
const data = JSON.parse(event.data);
handleCustomEvent(data);
});
// Close connection
eventSource.close();
Choosing Between WebSockets and SSE
Use WebSockets When
- Bidirectional communication needed
- Low latency required
- High frequency messages
- Binary data transmission
Examples: Chat apps, gaming, collaborative editing
Use SSE When
- One-way communication (server → client)
- Simple implementation preferred
- Automatic reconnection needed
- HTTP compatibility required
Examples: Notifications, live feeds, progress updates
Scaling Real-Time Applications
Challenge: Single Server Limitation
WebSocket connections are stateful. With multiple servers, a client might connect to Server A, but the message needs to go to Server B.
Solution: Message Broker
// Using Redis Pub/Sub
const redis = require('redis');
const publisher = redis.createClient();
const subscriber = redis.createClient();
// Server A receives message
wss.on('connection', (ws) => {
ws.on('message', (message) => {
const data = JSON.parse(message);
// Publish to Redis
publisher.publish('messages', JSON.stringify(data));
});
});
// All servers subscribe
subscriber.subscribe('messages');
subscriber.on('message', (channel, message) => {
const data = JSON.parse(message);
// Broadcast to local clients
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
Using Socket.io (Production-Ready)
Socket.io handles scaling, reconnection, and fallbacks:
// Server
const io = require('socket.io')(server);
const redisAdapter = require('socket.io-redis');
io.adapter(redisAdapter({ host: 'localhost', port: 6379 }));
io.on('connection', (socket) => {
socket.on('chat message', (msg) => {
io.emit('chat message', msg);
});
});
// Client
const socket = io();
socket.on('connect', () => {
console.log('Connected');
});
socket.on('chat message', (msg) => {
displayMessage(msg);
});
socket.emit('chat message', 'Hello!');
Best Practices
1. Authentication
// Authenticate on connection
wss.on('connection', (ws, req) => {
const token = getTokenFromRequest(req);
verifyToken(token)
.then(user => {
ws.userId = user.id;
// Connection established
})
.catch(() => {
ws.close(1008, 'Unauthorized');
});
});
2. Heartbeat/Ping-Pong
// Server
setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
ws.on('pong', () => {
ws.isAlive = true;
});
3. Rate Limiting
const rateLimiter = new Map();
wss.on('connection', (ws, req) => {
const ip = req.socket.remoteAddress;
const limiter = {
count: 0,
resetTime: Date.now() + 60000
};
rateLimiter.set(ip, limiter);
ws.on('message', () => {
const limiter = rateLimiter.get(ip);
if (Date.now() > limiter.resetTime) {
limiter.count = 0;
limiter.resetTime = Date.now() + 60000;
}
if (limiter.count > 100) {
ws.close(1008, 'Rate limit exceeded');
return;
}
limiter.count++;
});
});
4. Error Handling
ws.on('error', (error) => {
console.error('WebSocket error:', error);
// Log error
// Notify monitoring
// Clean up resources
});
ws.on('close', (code, reason) => {
console.log(`Connection closed: ${code} - ${reason}`);
// Clean up
});
Real-World Example
Feature: Real-time order updates for e-commerce
Requirements:
- Customers see order status updates
- Support team sees new orders
- Admin sees system metrics
Implementation:
// WebSocket for bidirectional (admin dashboard)
const adminWs = new WebSocket('ws://api/admin');
// SSE for one-way (customer updates)
const customerSSE = new EventSource('/api/orders/123/updates');
// Different channels
io.on('connection', (socket) => {
socket.on('join-room', (room) => {
socket.join(room);
});
});
// Send to specific room
io.to('order-123').emit('status-update', {
orderId: '123',
status: 'shipped',
timestamp: Date.now()
});
Result:
- Real-time updates for all users
- Scalable across multiple servers
- Handles 10,000+ concurrent connections
Conclusion
Real-time features are essential in modern applications. The key is choosing the right tool:
- WebSockets - Full-duplex, low latency
- SSE - Simple, one-way, HTTP-compatible
- Socket.io - Production-ready, handles scaling
Remember: Start simple, add complexity when needed. Real-time features can be challenging, but with the right patterns and tools, they're manageable.
What real-time features have you built? What challenges did you face? I'd love to hear about your experiences.
Related Posts
Optimizing Frontend Performance: Beyond Code Splitting
Advanced frontend performance optimization techniques that go beyond basic code splitting. Learn how to achieve sub-second load times and smooth 60fps interactions.
Performance Optimization in Node.js: Real-World Techniques
Discover practical Node.js performance optimization techniques that have helped applications handle millions of requests. From async patterns to memory management.
Building Scalable React Applications: Lessons from Production
Learn from real-world production experiences how to build React applications that scale gracefully. Discover patterns, pitfalls, and best practices that have proven effective in large-scale applications.