liminfo

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

WebSocket real-timeSocket.IO implementationWebSocket chatreal-time notification systemWebSocket authenticationSocket.IO RoomWebSocket scalingNode.js WebSocket

Problem

In a project management tool, when a team member updates a task, it needs to be reflected to other members immediately. Currently, HTTP polling (every 5 seconds) is used to check for changes, but the latency is too long and server load is high. Various real-time features are needed, including real-time chat, task change notifications, and live editing status display, and it must work reliably in a multi-server environment. WebSocket needs to be introduced to build a zero-latency bidirectional communication system.

Required Tools

Socket.IO

A real-time communication library that abstracts WebSocket. Supports auto-reconnection, Room/Namespace, and fallback (long-polling).

Node.js + Express

The backend runtime hosting the Socket.IO server. Runs HTTP and WebSocket servers on the same port.

Redis Adapter

An adapter that synchronizes events across multiple Socket.IO server instances. Essential for horizontal scaling.

React (Client)

The Socket.IO client library for receiving real-time events and updating UI in the browser.

Solution Steps

1

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');
});
2

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();
    });
  }
});
3

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 });
  });
});
4

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,
    });
  });
});
5

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>
  );
}
6

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.

Related liminfo Services