Building Real-Time Apps with WebSockets
You add a chat widget or a notification bell, wire it up with a setInterval that hits /api/messages every five seconds, and it works. Then you look at the server logs and see that almost every one of those requests came back empty, messages still show up a few seconds late, and the request count climbs with every open tab. That's the point where WebSockets start making sense: one long-lived connection that either side can send on whenever it wants.
This post goes through how the protocol works, the native browser API with a small ws server, then Socket.io for rooms, a chat server, notifications, scaling with Redis, security, reconnection and testing. I'll finish with when I'd reach for WebSockets and when I wouldn't.
- How WebSockets differ from traditional HTTP
- The WebSocket handshake and protocol mechanics
- Building real-time apps with native WebSocket API
- Socket.io: rooms, reconnection and multi-server setups
- Common patterns: chat, notifications, live dashboards
- Security, reconnection and testing
Why Plain HTTP Struggles With Real-Time
HTTP is request and response. The server can't speak until the client asks, so "push" has to be faked:
Traditional HTTP
Client → Request → Server
Client ← Response ← Server
// Connection closes
// For updates:
setInterval(() => {
fetch('/api/messages')
}, 5000); // Polling
WebSockets
Client ↔ Server
// Persistent connection!
// Instant updates:
socket.on('message', (data) => {
// Received immediately
displayMessage(data);
});
The Problems with Polling
Before WebSockets were widely supported, these were the usual workarounds. They still have their place:
| Technique | How It Works | Drawbacks |
|---|---|---|
| Short Polling | Client repeatedly asks server for updates | High latency, wasted requests, server load |
| Long Polling | Server holds request until data available | Connection overhead, timeout management |
| Server-Sent Events | One-way server → client stream | Client sends data with normal HTTP requests; fine for feeds and dashboards |
| WebSockets | Full-duplex bidirectional connection | Stateful connections are harder to scale and proxy |
How WebSockets Work
The connection starts life as an ordinary HTTP request. Once the server agrees to upgrade, the same TCP connection stays open and either side can send frames at any time.
WebSocket connection establishment: HTTP upgrade handshake followed by persistent bidirectional communication
The WebSocket Handshake
The handshake is three steps:
1. Client Sends Upgrade Request
The client sends a special HTTP request with Upgrade: websocket header and a unique key.
2. Server Responds with 101
If the server supports WebSockets, it responds with 101 Switching Protocols and an accept key.
3. Connection Established
The TCP connection upgrades to WebSocket. Both parties can now send messages freely.
// Client Request
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
// Server Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Native WebSocket API in JavaScript
Every current browser ships a WebSocket class, so you don't need a library to get started. It's worth writing a client by hand once so you know what libraries are doing for you:
// Create WebSocket connection
const socket = new WebSocket('ws://localhost:8080');
// Connection opened
socket.addEventListener('open', (event) => {
console.log('Connected to server!');
socket.send('Hello Server!');
});
// Listen for messages
socket.addEventListener('message', (event) => {
console.log('Message from server:', event.data);
});
// Handle errors
socket.addEventListener('error', (event) => {
console.error('WebSocket error:', event);
});
// Connection closed
socket.addEventListener('close', (event) => {
console.log('Disconnected from server');
if (event.wasClean) {
console.log(`Code: ${event.code}, Reason: ${event.reason}`);
} else {
console.log('Connection died unexpectedly');
}
});
// Send data
function sendMessage(message) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'chat', data: message }));
}
}
Creating a WebSocket Server with Node.js
On the server, ws is the usual choice in Node. It's small and doesn't add its own protocol on top:
const WebSocket = require('ws');
// Create WebSocket server on port 8080
const wss = new WebSocket.Server({ port: 8080 });
// Store connected clients
const clients = new Set();
wss.on('connection', (ws, req) => {
const clientIP = req.socket.remoteAddress;
console.log(`New client connected: ${clientIP}`);
// Add to clients set
clients.add(ws);
// Send welcome message
ws.send(JSON.stringify({
type: 'welcome',
message: 'Connected to WebSocket server!'
}));
// Handle incoming messages
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
console.log('Received:', message);
// Broadcast to all clients
broadcast(message, ws);
} catch (e) {
console.error('Invalid JSON:', e);
}
});
// Handle client disconnect
ws.on('close', () => {
console.log('Client disconnected');
clients.delete(ws);
});
// Handle errors
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
});
// Broadcast message to all clients except sender
function broadcast(message, sender) {
clients.forEach((client) => {
if (client !== sender && client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
});
}
console.log('WebSocket server running on ws://localhost:8080');
readyState is one of four values. Calling send() while it's still CONNECTING throws an InvalidStateError, which is why the client above checks for OPEN first.
CONNECTING (0): handshake in progressOPEN (1): ready to send and receiveCLOSING (2): close handshake startedCLOSED (3): closed or never opened
The most common "works locally, fails in production" problem: Nginx doesn't pass Upgrade and Connection through by default, so the handshake never completes and the browser logs WebSocket connection to 'wss://...' failed. Add proxy_http_version 1.1;, proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade"; to the location block. Also note that proxy_read_timeout defaults to 60 seconds, so an idle connection with no ping traffic will get closed by the proxy.
Scaling with Socket.io
Raw WebSockets give you a pipe and nothing else. Reconnection, grouping clients, and acknowledgements are all on you. Socket.io adds those on top. One thing to know up front: it uses its own protocol, so a Socket.io client can't talk to a plain ws server and vice versa.
Automatic Reconnection
Retries dropped connections with exponential backoff.
Room Support
Group clients into rooms and broadcast to just that group.
Fallback Transports
Falls back to long-polling if WebSockets aren't available.
Namespace Support
Multiplex connections over a single socket with namespaces.
Setting Up Socket.io
# Install Socket.io for server
npm install socket.io
# Install Socket.io client (for Node.js clients)
npm install socket.io-client
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "http://localhost:3000",
methods: ["GET", "POST"]
}
});
// Connection handler
io.on('connection', (socket) => {
console.log('User connected:', socket.id);
// Join a room
socket.on('join-room', (roomId) => {
socket.join(roomId);
console.log(`User ${socket.id} joined room ${roomId}`);
// Notify others in the room
socket.to(roomId).emit('user-joined', {
userId: socket.id,
message: 'A new user joined the room'
});
});
// Handle chat messages
socket.on('chat-message', (data) => {
// Emit to specific room
io.to(data.room).emit('new-message', {
userId: socket.id,
message: data.message,
timestamp: new Date().toISOString()
});
});
// Handle typing indicator
socket.on('typing', (data) => {
socket.to(data.room).emit('user-typing', {
userId: socket.id
});
});
// Handle disconnect
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
});
});
httpServer.listen(3001, () => {
console.log('Socket.io server running on port 3001');
});
<!-- Include Socket.io client -->
<script src="/socket.io/socket.io.js"></script>
<script>
// Connect to server
const socket = io('http://localhost:3001');
// Connection events
socket.on('connect', () => {
console.log('Connected:', socket.id);
// Join a chat room
socket.emit('join-room', 'general');
});
// Listen for new messages
socket.on('new-message', (data) => {
displayMessage(data);
});
// Listen for typing indicator
socket.on('user-typing', (data) => {
showTypingIndicator(data.userId);
});
// Send a message
function sendMessage(message) {
socket.emit('chat-message', {
room: 'general',
message: message
});
}
// Emit typing event
function onTyping() {
socket.emit('typing', { room: 'general' });
}
// Handle disconnection
socket.on('disconnect', () => {
console.log('Disconnected from server');
});
// Reconnection handling
socket.on('reconnect', (attemptNumber) => {
console.log(`Reconnected after ${attemptNumber} attempts`);
});
</script>
Building a Real-Time Chat Application
Here's a fuller chat server with rooms, presence and a short message history. Storage is in memory to keep it readable, so it forgets everything on restart:
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer);
// In-memory storage (use Redis/DB in production)
const rooms = new Map();
const users = new Map();
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`);
// User authentication
socket.on('authenticate', (userData) => {
const user = {
id: socket.id,
name: userData.name,
avatar: userData.avatar || getDefaultAvatar(userData.name)
};
users.set(socket.id, user);
socket.emit('authenticated', user);
});
// Join a chat room
socket.on('join-room', (roomId) => {
const user = users.get(socket.id);
if (!user) return socket.emit('error', 'Not authenticated');
socket.join(roomId);
// Initialize room if not exists
if (!rooms.has(roomId)) {
rooms.set(roomId, {
messages: [],
users: new Set()
});
}
const room = rooms.get(roomId);
room.users.add(socket.id);
// Send room history
socket.emit('room-history', room.messages.slice(-50));
// Broadcast user joined
io.to(roomId).emit('user-presence', {
type: 'joined',
user: user,
onlineCount: room.users.size
});
});
// Handle new message
socket.on('send-message', ({ roomId, content }) => {
const user = users.get(socket.id);
if (!user) return;
const message = {
id: generateId(),
userId: socket.id,
userName: user.name,
avatar: user.avatar,
content: content,
timestamp: new Date().toISOString()
};
// Store message
const room = rooms.get(roomId);
if (room) {
room.messages.push(message);
// Keep last 100 messages
if (room.messages.length > 100) {
room.messages.shift();
}
}
// Broadcast to room
io.to(roomId).emit('new-message', message);
});
// Typing indicator
let typingTimeout;
socket.on('typing', ({ roomId }) => {
const user = users.get(socket.id);
if (!user) return;
socket.to(roomId).emit('user-typing', {
userId: socket.id,
userName: user.name
});
// Auto-clear typing after 3 seconds
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
socket.to(roomId).emit('user-stopped-typing', {
userId: socket.id
});
}, 3000);
});
// Leave room
socket.on('leave-room', (roomId) => {
handleLeaveRoom(socket, roomId);
});
// Disconnect
socket.on('disconnect', () => {
const user = users.get(socket.id);
// Leave all rooms
rooms.forEach((room, roomId) => {
if (room.users.has(socket.id)) {
handleLeaveRoom(socket, roomId);
}
});
users.delete(socket.id);
console.log(`Client disconnected: ${socket.id}`);
});
});
function handleLeaveRoom(socket, roomId) {
const user = users.get(socket.id);
const room = rooms.get(roomId);
if (room && user) {
room.users.delete(socket.id);
socket.leave(roomId);
io.to(roomId).emit('user-presence', {
type: 'left',
user: user,
onlineCount: room.users.size
});
// Clean up empty rooms
if (room.users.size === 0) {
rooms.delete(roomId);
}
}
}
function generateId() {
return Math.random().toString(36).substr(2, 9);
}
function getDefaultAvatar(name) {
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}`;
}
httpServer.listen(3001, () => {
console.log('Chat server running on http://localhost:3001');
});
Common Real-Time Use Cases
The patterns I see most often:
Chat Applications
Instant messaging, group chats, customer support widgets
Live Notifications
Social media alerts, email notifications, activity feeds
Live Dashboards
Stock tickers, analytics, monitoring systems
Multiplayer Games
Real-time game state, player positions, game events
Collaborative Editing
Google Docs-style real-time document editing
Location Tracking
Delivery tracking, ride-sharing, fleet management
Live Notifications System
// Server-side notification service
class NotificationService {
constructor(io) {
this.io = io;
this.userSockets = new Map(); // userId -> Set(socketIds)
}
// Register user socket
registerUser(userId, socketId) {
if (!this.userSockets.has(userId)) {
this.userSockets.set(userId, new Set());
}
this.userSockets.get(userId).add(socketId);
}
// Unregister user socket
unregisterUser(userId, socketId) {
const sockets = this.userSockets.get(userId);
if (sockets) {
sockets.delete(socketId);
if (sockets.size === 0) {
this.userSockets.delete(userId);
}
}
}
// Send notification to specific user
sendToUser(userId, notification) {
const sockets = this.userSockets.get(userId);
if (sockets) {
const data = {
id: generateId(),
...notification,
timestamp: new Date().toISOString(),
read: false
};
sockets.forEach(socketId => {
this.io.to(socketId).emit('notification', data);
});
}
}
// Send notification to multiple users
sendToUsers(userIds, notification) {
userIds.forEach(userId => {
this.sendToUser(userId, notification);
});
}
// Broadcast to all connected users
broadcast(notification) {
this.io.emit('notification', {
id: generateId(),
...notification,
timestamp: new Date().toISOString()
});
}
}
// Usage with Socket.io
const notificationService = new NotificationService(io);
io.on('connection', (socket) => {
socket.on('register', (userId) => {
notificationService.registerUser(userId, socket.id);
});
socket.on('disconnect', () => {
// Find and unregister user
notificationService.userSockets.forEach((sockets, userId) => {
if (sockets.has(socket.id)) {
notificationService.unregisterUser(userId, socket.id);
}
});
});
});
// Example: Send notification when someone follows a user
app.post('/api/follow', async (req, res) => {
const { followerId, followedId } = req.body;
// Save to database
await saveFollow(followerId, followedId);
// Send real-time notification
const follower = await getUser(followerId);
notificationService.sendToUser(followedId, {
type: 'follow',
title: 'New Follower',
message: `${follower.name} started following you`,
avatar: follower.avatar,
link: `/profile/${followerId}`
});
res.json({ success: true });
});
Scaling WebSocket Applications
Everything above assumes one Node process. As soon as you run two (PM2 cluster mode counts), it breaks in a confusing way: some users get messages and some don't.
WebSocket connections are stateful. If you have multiple servers, a message from User A on Server 1 won't reach User B on Server 2 unless something relays it. With Socket.io you also need sticky sessions at the load balancer, because the HTTP long-polling fallback sends several requests that must all land on the same server. Without them you'll see repeated 400 Bad Request responses with "Session ID unknown".
Using Redis Adapter for Socket.io
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const io = new Server(httpServer);
// Create Redis clients
const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();
// Wait for Redis connection
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
// Attach Redis adapter
io.adapter(createAdapter(pubClient, subClient));
console.log('Socket.io with Redis adapter ready');
});
// Now events are broadcast across all server instances
io.on('connection', (socket) => {
socket.on('chat-message', (data) => {
// This will reach all clients on ALL servers
io.to(data.room).emit('new-message', data);
});
});
Horizontal scaling with Redis adapter enables cross-server communication
Security Best Practices
A WebSocket endpoint is an API endpoint that stays open, so it needs the same care as your REST routes. Browsers don't apply CORS to WebSocket connections the way they do to fetch, so check the Origin header yourself if you rely on cookies.
WebSocket Security Checklist
- Always use
wss://(WebSocket Secure) in production - Validate and authenticate connections on handshake
- Implement rate limiting to prevent abuse
- Sanitize all incoming messages to prevent XSS
- Set appropriate CORS policies
- Implement heartbeat/ping-pong for connection health
- Use JWT or session tokens for authentication
- Implement message size limits
- Log and monitor WebSocket connections
const { Server } = require('socket.io');
const jwt = require('jsonwebtoken');
const rateLimit = require('./rateLimiter');
const io = new Server(httpServer, {
cors: {
origin: ['https://yourdomain.com'],
credentials: true
},
// Connection timeout
pingTimeout: 60000,
pingInterval: 25000,
// Max payload size (1MB)
maxHttpBufferSize: 1e6
});
// Authentication middleware
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication required'));
}
// Verify JWT token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Attach user to socket
socket.user = decoded;
// Check if user is banned/disabled
const user = await getUserById(decoded.id);
if (!user || user.banned) {
return next(new Error('User not authorized'));
}
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
// Rate limiting middleware
io.use((socket, next) => {
const clientIP = socket.handshake.address;
if (rateLimit.isLimited(clientIP)) {
return next(new Error('Too many connections'));
}
rateLimit.increment(clientIP);
next();
});
io.on('connection', (socket) => {
// Message validation and sanitization
socket.on('message', (data) => {
// Validate message structure
if (!isValidMessage(data)) {
return socket.emit('error', 'Invalid message format');
}
// Sanitize content
const sanitized = sanitizeHtml(data.content, {
allowedTags: [],
allowedAttributes: {}
});
// Size check
if (sanitized.length > 5000) {
return socket.emit('error', 'Message too long');
}
// Process sanitized message
processMessage(socket, { ...data, content: sanitized });
});
});
function isValidMessage(data) {
return (
typeof data === 'object' &&
typeof data.room === 'string' &&
typeof data.content === 'string' &&
data.room.length > 0 &&
data.room.length <= 50
);
}
Handling Connection Issues
Phones switch networks, laptops sleep, proxies time out idle connections. If you use the native API you have to write reconnection yourself. Add jitter to the delay so that when a server restarts, all clients don't reconnect at the same instant:
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.options = {
maxRetries: 10,
initialDelay: 1000,
maxDelay: 30000,
factor: 2,
...options
};
this.retries = 0;
this.listeners = new Map();
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.retries = 0;
this.emit('connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.emit('message', data);
};
this.ws.onclose = (event) => {
if (!event.wasClean) {
this.reconnect();
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
reconnect() {
if (this.retries >= this.options.maxRetries) {
this.emit('maxRetriesReached');
return;
}
// Exponential backoff with jitter
const delay = Math.min(
this.options.initialDelay * Math.pow(this.options.factor, this.retries),
this.options.maxDelay
) * (0.5 + Math.random() * 0.5);
console.log(`Reconnecting in ${Math.round(delay)}ms...`);
this.emit('reconnecting', { attempt: this.retries + 1, delay });
setTimeout(() => {
this.retries++;
this.connect();
}, delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('Cannot send: WebSocket not open');
}
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
emit(event, data) {
const callbacks = this.listeners.get(event) || [];
callbacks.forEach(cb => cb(data));
}
}
// Usage
const ws = new ReconnectingWebSocket('wss://api.example.com/ws');
ws.on('connected', () => {
console.log('Ready to send messages');
});
ws.on('message', (data) => {
console.log('Received:', data);
});
ws.on('reconnecting', ({ attempt, delay }) => {
showNotification(`Connection lost. Reconnecting (attempt ${attempt})...`);
});
Testing WebSocket Applications
- wscat: Command-line WebSocket client (
npm install -g wscat) - Postman: Supports WebSocket testing in recent versions
- Browser DevTools: Network tab → WS filter
- socket.io-client: For automated testing with Jest/Mocha
const { createServer } = require('http');
const { Server } = require('socket.io');
const Client = require('socket.io-client');
describe('WebSocket Server', () => {
let io, serverSocket, clientSocket;
beforeAll((done) => {
const httpServer = createServer();
io = new Server(httpServer);
httpServer.listen(() => {
const port = httpServer.address().port;
clientSocket = Client(`http://localhost:${port}`);
io.on('connection', (socket) => {
serverSocket = socket;
});
clientSocket.on('connect', done);
});
});
afterAll(() => {
io.close();
clientSocket.close();
});
test('should receive message from client', (done) => {
serverSocket.on('chat', (message) => {
expect(message).toBe('Hello Server');
done();
});
clientSocket.emit('chat', 'Hello Server');
});
test('should broadcast message to all clients', (done) => {
clientSocket.on('broadcast', (message) => {
expect(message).toBe('Hello Everyone');
done();
});
serverSocket.emit('broadcast', 'Hello Everyone');
});
test('should join room and receive room messages', (done) => {
clientSocket.emit('join', 'test-room');
clientSocket.on('room-message', (data) => {
expect(data.room).toBe('test-room');
expect(data.message).toBe('Welcome!');
done();
});
serverSocket.on('join', (room) => {
serverSocket.join(room);
io.to(room).emit('room-message', {
room: room,
message: 'Welcome!'
});
});
});
});
Performance Optimization Tips
Message Compression
perMessageDeflate compresses frames and cuts bandwidth, but it costs CPU and memory per connection. Test it under load before turning it on.
Selective Broadcasting
Use rooms and namespaces to send messages only to relevant clients.
Binary Data
Send binary data instead of JSON for large payloads like images or files.
Connection Pooling
Limit connections per user and implement proper cleanup on disconnect.
When I'd Use WebSockets (and When I Wouldn't)
I reach for WebSockets when both sides genuinely talk to each other often: chat, collaborative editing, multiplayer state, anything with typing indicators or presence. In those cases polling is either too slow or too wasteful, and SSE leaves you sending every client action as a separate HTTP request.
I wouldn't use them for things that only flow one way. A notification bell, a build log, a live order status: Server-Sent Events handle those over plain HTTP, reconnect on their own, and go through most proxies and CDNs without special config. If updates arrive every minute or so, a 30 second poll is boring and perfectly fine, and it scales like any other endpoint.
What to Check Before Going to Production
- Your reverse proxy forwards
Upgrade/Connectionand its read timeout is longer than your ping interval - Sticky sessions are on if you run more than one Socket.io instance, plus the Redis adapter
- Authentication happens during the handshake, not in the first message
- Clients refetch missed data after a reconnect; messages sent while offline are simply lost
- You have a limit on open file descriptors (
ulimit -n) high enough for the number of connections you expect - Deploys drain connections gracefully, or you've accepted that every deploy disconnects everyone at once