Real-Time Communication with WebSocket
Go beyond HTTP polling limitations to implement bidirectional real-time communication with WebSocket, and build chat, notifications, and live dashboards with Socket.IO
Problem
Required Tools
A real-time communication library that abstracts WebSocket. Supports auto-reconnection, Room/Namespace, and fallback (long-polling).
The backend runtime hosting the Socket.IO server. Runs HTTP and WebSocket servers on the same port.
An adapter that synchronizes events across multiple Socket.IO server instances. Essential for horizontal scaling.
The Socket.IO client library for receiving real-time events and updating UI in the browser.
Solution Steps
Socket.IO Server Setup and Basic Event Handling
Set up a Socket.IO server integrated with Express and handle basic connection/disconnection events. Allow client domain access with cors configuration, and log connection states. Use io.emit() for full broadcast and socket.emit() for individual transmission.
// server.ts - Socket.IO server setup
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: ['http://localhost:3000', 'https://myapp.com'],
credentials: true,
},
pingInterval: 25000, // Heartbeat interval
pingTimeout: 20000, // Timeout
maxHttpBufferSize: 1e6, // Max message size (1MB)
});
// Connected client management
const connectedUsers = new Map<string, { userId: string; socketId: string }>();
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id}`);
// Basic event handling
socket.on('message', (data) => {
console.log(`Message from ${socket.id}:`, data);
// Broadcast to everyone except sender
socket.broadcast.emit('message', data);
});
// Disconnection
socket.on('disconnect', (reason) => {
console.log(`Client disconnected: ${socket.id}, reason: ${reason}`);
connectedUsers.delete(socket.id);
io.emit('userLeft', { socketId: socket.id });
});
// Error handling
socket.on('error', (error) => {
console.error(`Socket error: ${socket.id}`, error);
});
});
httpServer.listen(4000, () => {
console.log('Socket.IO server running on port 4000');
});WebSocket Authentication (Middleware) Implementation
Verify JWT tokens in Socket.IO middleware to allow only authenticated users to connect. Tokens are passed through the auth object during the handshake phase, and connection is rejected on verification failure. Storing authenticated user info in socket.data makes it accessible in all subsequent event handlers.
// auth-middleware.ts - WebSocket authentication middleware
import { Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
userName: string;
role: string;
};
}
// Socket.IO authentication middleware
io.use(async (socket: AuthenticatedSocket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication token missing'));
}
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string; userName: string; role: string;
};
// Store user info in socket.data
socket.data.userId = decoded.userId;
socket.data.userName = decoded.userName;
socket.data.role = decoded.role;
next();
} catch (error) {
next(new Error('Authentication failed: invalid token'));
}
});
// Using auth info in event handlers
io.on('connection', (socket: AuthenticatedSocket) => {
const { userId, userName } = socket.data;
console.log(`Authenticated user connected: ${userName} (${userId})`);
// Auto-join personal room by user ID
socket.join(`user:${userId}`);
// Send notification to specific user
io.to(`user:${targetUserId}`).emit('notification', {
type: 'mention',
message: `${userName} mentioned you`,
});
});
// Client connection code (React)
import { io } from 'socket.io-client';
const socket = io('http://localhost:4000', {
auth: {
token: localStorage.getItem('accessToken'),
},
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
socket.on('connect_error', (error) => {
if (error.message === 'Authentication failed: invalid token') {
// Token refresh logic
refreshToken().then((newToken) => {
socket.auth = { token: newToken };
socket.connect();
});
}
});Room-Based Chat and Group Messaging
Rooms logically group sockets to send messages to specific groups only. Project channels, 1:1 conversations, admin-only channels, etc. can all be implemented with Rooms. Use socket.join() to join a Room and io.to(roomName).emit() to send events only to that Room.
// rooms.ts - Room-based chat system
io.on('connection', (socket: AuthenticatedSocket) => {
const { userId, userName } = socket.data;
// Join project channel
socket.on('joinProject', async (projectId: string) => {
// Permission check (verify project membership)
const isMember = await checkProjectMembership(userId, projectId);
if (!isMember) {
socket.emit('error', { message: 'No access to this project' });
return;
}
socket.join(`project:${projectId}`);
// Notify project members of entry
socket.to(`project:${projectId}`).emit('userJoined', {
userId, userName, timestamp: new Date()
});
// Send current online member list
const roomSockets = await io.in(`project:${projectId}`).fetchSockets();
const onlineMembers = roomSockets.map(s => ({
userId: s.data.userId,
userName: s.data.userName,
}));
socket.emit('onlineMembers', onlineMembers);
});
// Send chat message
socket.on('chatMessage', async (data: {
projectId: string;
message: string;
replyTo?: string;
}) => {
const chatMessage = {
id: generateId(),
userId,
userName,
message: data.message,
replyTo: data.replyTo,
timestamp: new Date(),
};
// Save message to DB
await saveChatMessage(data.projectId, chatMessage);
// Send only to the project Room
io.to(`project:${data.projectId}`).emit('newMessage', chatMessage);
});
// Typing indicator
socket.on('typing', (data: { projectId: string; isTyping: boolean }) => {
socket.to(`project:${data.projectId}`).emit('userTyping', {
userId, userName, isTyping: data.isTyping,
});
});
// Leave project channel
socket.on('leaveProject', (projectId: string) => {
socket.leave(`project:${projectId}`);
socket.to(`project:${projectId}`).emit('userLeft', { userId, userName });
});
});Real-Time Notifications and Task Change Events
When data changes via REST API, send real-time notifications to relevant users through WebSocket. Distinguish by event type so clients can selectively handle them, and manage notification queues for offline users. Use socket.volatile.emit() for transient data (cursor positions, etc.) that does not require delivery guarantees.
// notifications.ts - Real-time notification system
// Emit WebSocket events from REST API
// (using io instance in Express router)
app.put('/api/tasks/:taskId', async (req, res) => {
const task = await updateTask(req.params.taskId, req.body);
// Real-time notification to project members
io.to(`project:${task.projectId}`).emit('taskUpdated', {
taskId: task.id,
changes: req.body,
updatedBy: req.user.name,
timestamp: new Date(),
});
// Personal notification to assignee
if (task.assigneeId && task.assigneeId !== req.user.id) {
io.to(`user:${task.assigneeId}`).emit('notification', {
type: 'task_update',
title: 'Task updated',
body: `${req.user.name} modified task "${task.title}"`,
link: `/projects/${task.projectId}/tasks/${task.id}`,
timestamp: new Date(),
});
// Also save notification to DB for offline users
await saveNotification(task.assigneeId, {
type: 'task_update',
taskId: task.id,
message: `${req.user.name} modified the task`,
});
}
res.json(task);
});
// Live cursor / editing state (volatile - loss acceptable)
io.on('connection', (socket: AuthenticatedSocket) => {
socket.on('cursorMove', (data: {
projectId: string;
taskId: string;
position: { x: number; y: number };
}) => {
// volatile: delivery guarantee not needed (next event is coming soon)
socket.volatile.to(`project:${data.projectId}`).emit('cursorUpdate', {
userId: socket.data.userId,
userName: socket.data.userName,
taskId: data.taskId,
position: data.position,
});
});
});React Client Custom Hook Implementation
Wrap the Socket.IO client in custom hooks in React for reusability. Register event listeners in useEffect and unregister them in cleanup to prevent memory leaks. Sharing the socket instance globally through Context maintains a consistent connection across components.
// hooks/useSocket.ts - React Socket.IO custom hook
import { useEffect, useState, useCallback, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
// Socket singleton instance
let socket: Socket | null = null;
function getSocket(token: string): Socket {
if (!socket) {
socket = io(process.env.NEXT_PUBLIC_WS_URL!, {
auth: { token },
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
});
}
return socket;
}
// Project chat hook
export function useProjectChat(projectId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
const s = getSocket(token);
socketRef.current = s;
s.on('connect', () => setIsConnected(true));
s.on('disconnect', () => setIsConnected(false));
// Join project channel
s.emit('joinProject', projectId);
// Receive new messages
s.on('newMessage', (msg: ChatMessage) => {
setMessages(prev => [...prev, msg]);
});
// Typing indicator
s.on('userTyping', ({ userName, isTyping }) => {
setTypingUsers(prev =>
isTyping
? [...new Set([...prev, userName])]
: prev.filter(u => u !== userName)
);
});
return () => {
s.emit('leaveProject', projectId);
s.off('newMessage');
s.off('userTyping');
};
}, [projectId]);
const sendMessage = useCallback((message: string, replyTo?: string) => {
socketRef.current?.emit('chatMessage', { projectId, message, replyTo });
}, [projectId]);
const setTyping = useCallback((isTyping: boolean) => {
socketRef.current?.emit('typing', { projectId, isTyping });
}, [projectId]);
return { messages, typingUsers, isConnected, sendMessage, setTyping };
}
// Usage in component
function ChatRoom({ projectId }: { projectId: string }) {
const { messages, typingUsers, isConnected, sendMessage, setTyping } =
useProjectChat(projectId);
return (
<div>
{!isConnected && <div className="bg-yellow-100">Reconnecting...</div>}
{messages.map(msg => <ChatBubble key={msg.id} message={msg} />)}
{typingUsers.length > 0 && (
<p>{typingUsers.join(', ')} is typing...</p>
)}
</div>
);
}Multi-Server Scaling with Redis Adapter
When running multiple Socket.IO server instances in production, a Redis Adapter is essential. To ensure events are delivered correctly between clients connected to different servers, inter-server synchronization via Redis Pub/Sub is needed. Using Nginx sticky sessions or IP hash to route the same client to the same server improves stability.
// Redis Adapter setup (multi-server environment)
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(httpServer, { /* ... */ });
io.adapter(createAdapter(pubClient, subClient));
// Nginx configuration (WebSocket + sticky session)
// upstream socket_nodes {
// ip_hash; # Same IP -> same server
// server 127.0.0.1:4001;
// server 127.0.0.1:4002;
// server 127.0.0.1:4003;
// }
//
// server {
// location /socket.io/ {
// proxy_pass http://socket_nodes;
// proxy_http_version 1.1;
// proxy_set_header Upgrade $http_upgrade;
// proxy_set_header Connection "upgrade";
// proxy_set_header Host $host;
// }
// }
// Connection state monitoring
setInterval(async () => {
const sockets = await io.fetchSockets();
console.log(`Connected clients: ${sockets.length}`);
// User count per Room
const rooms = io.sockets.adapter.rooms;
for (const [roomName, sids] of rooms) {
if (roomName.startsWith('project:')) {
console.log(` ${roomName}: ${sids.size} users`);
}
}
}, 30000);Core Code
Core structure of Socket.IO-based real-time communication. Integrates auth middleware, Room-based group messaging, REST API and WebSocket event bridging, and Redis Adapter scaling.
// WebSocket Real-Time Communication Core Structure (Socket.IO)
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
// 1. Server setup
const io = new Server(httpServer, { cors: { origin: '*' } });
io.adapter(createAdapter(pubClient, subClient)); // Multi-server support
// 2. Auth middleware
io.use((socket, next) => {
const user = verifyToken(socket.handshake.auth.token);
if (!user) return next(new Error('Unauthorized'));
socket.data = user;
next();
});
// 3. Event handling
io.on('connection', (socket) => {
socket.join(`user:${socket.data.userId}`); // Personal Room
socket.on('joinProject', (id) => socket.join(`project:${id}`)); // Group Room
socket.on('chatMessage', async (data) => {
await saveToDB(data);
io.to(`project:${data.projectId}`).emit('newMessage', data); // Room broadcast
});
socket.on('typing', (data) => {
socket.volatile.to(`project:${data.projectId}`).emit('userTyping', data);
});
});
// 4. Emit WebSocket events from REST API
app.put('/api/tasks/:id', async (req, res) => {
const task = await updateTask(req.params.id, req.body);
io.to(`project:${task.projectId}`).emit('taskUpdated', task);
res.json(task);
});Common Mistakes
Memory leaks from not removing event listeners in useEffect cleanup
Listeners registered with socket.on() in React's useEffect must be removed with socket.off() in the return function. Otherwise, listeners are duplicated on each component remount, causing memory leaks and duplicate event processing.
Deploying multiple servers without Redis Adapter, causing Room events to not reach some clients
When running multiple Socket.IO server instances without a Redis Adapter, each server manages Rooms independently. Always configure @socket.io/redis-adapter to synchronize events across servers.
Deploying without WebSocket authentication, allowing unauthorized access
Perform authentication with JWT tokens in the io.use() middleware. Calling next(new Error()) on authentication failure rejects the connection. The client also needs logic to automatically refresh tokens and reconnect on token expiration.