MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Building a Real-Time Chat Application with Socket.IO

Build a real-time chat app: Socket.IO rooms, typing indicators, message persistence, and presence.

Socket.IOReal-TimeNode.jsWebSocket

By MinhVo

Introduction

Real-time communication is the backbone of modern web applications. From Slack and Discord to live customer support and collaborative editing, users expect instant, bidirectional communication. WebSocket technology makes this possible, but raw WebSocket APIs are low-level and require significant boilerplate for reconnection, room management, and event handling. Socket.IO abstracts these concerns into a robust, production-ready library.

Socket.IO has been the de facto standard for real-time web applications since 2012. It provides automatic reconnection, room-based broadcasting, namespace isolation, binary data transport, and fallback to HTTP long-polling when WebSocket connections fail. This guide walks through building a production-grade chat application with Socket.IO, covering architecture, authentication, message persistence, typing indicators, and horizontal scaling.

Real-time communication

Understanding Socket.IO: Core Concepts

The Transport Layer

Socket.IO uses Engine.IO under the hood, which manages the actual transport. On the first connection, Socket.IO uses HTTP long-polling as a fallback, then upgrades to WebSocket if the connection supports it. This fallback mechanism ensures connectivity in restrictive network environments (corporate proxies, firewalls that block WebSocket).

Namespaces

Namespaces are logical separation points within a single Socket.IO connection. A single WebSocket connection can multiplex multiple namespaces, each with its own event handlers and middleware. Common patterns include:

  • /chat — Chat functionality
  • /notifications — Push notifications
  • /admin — Administrative operations

This multiplexing reduces the number of physical connections and simplifies authentication (authenticate once per namespace).

Rooms

Rooms are server-side abstractions for grouping sockets. A socket can join multiple rooms, and you can broadcast messages to all sockets in a room. Rooms are the foundation for:

  • Private conversations (1:1 chat rooms)
  • Group chats (multi-user rooms)
  • Channel-based messaging (topic rooms)

Events

Socket.IO is event-driven. Both client and server emit and listen for named events:

// Server
io.on("connection", (socket) => {
  socket.on("chat:message", (data) => {
    io.to(data.room).emit("chat:message", data);
  });
});
 
// Client
socket.emit("chat:message", { room: "general", text: "Hello!" });
socket.on("chat:message", (data) => console.log(data));

Socket.IO architecture

Architecture and Design Patterns

Server Architecture

A production Socket.IO server follows a layered architecture:

Client → Socket.IO Server → Event Handlers → Services → Database
                              ↓
                         Middleware (auth, rate limiting, validation)

Event-Driven Design

Chat applications are naturally event-driven. Each user action produces an event:

  • user:join — User joins a room
  • user:leave — User leaves a room
  • chat:message — New message sent
  • chat:typing — User started/stopped typing
  • chat:read — User read messages
  • presence:online — User came online
  • presence:offline — User went offline

Message Flow

User A types message → Client emits "chat:message" → Server validates →
Server persists to DB → Server broadcasts to room → All clients receive message

Acknowledgment Pattern

Socket.IO supports acknowledgments—callbacks that confirm message receipt:

// Client
socket.emit("chat:message", { text: "Hello" }, (response) => {
  if (response.status === "ok") {
    console.log("Message delivered, id:", response.messageId);
  }
});
 
// Server
socket.on("chat:message", async (data, callback) => {
  const message = await saveMessage(data);
  callback({ status: "ok", messageId: message.id });
});

Step-by-Step Implementation

Project Setup

mkdir chat-app && cd chat-app
npm init -y
npm install express socket.io mongoose jsonwebtoken bcrypt
npm install --save-dev typescript @types/express @types/node tsx

Server Implementation

// server/index.ts
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import { instrument } from "@socket.io/admin-ui";
import jwt from "jsonwebtoken";
 
const app = express();
const httpServer = createServer(app);
 
const io = new Server(httpServer, {
  cors: {
    origin: ["http://localhost:3000", "https://admin.socket.io"],
    credentials: true,
  },
  pingTimeout: 60000,
  pingInterval: 25000,
});
 
// Admin dashboard
instrument(io, { auth: false });
 
// Namespace for chat
const chatNamespace = io.of("/chat");
 
// Authentication middleware
chatNamespace.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) {
    return next(new Error("Authentication required"));
  }
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string; username: string };
    socket.data.userId = decoded.userId;
    socket.data.username = decoded.username;
    next();
  } catch (err) {
    next(new Error("Invalid token"));
  }
});
 
// Rate limiting middleware
const messageRates = new Map<string, { count: number; resetTime: number }>();
 
function rateLimit(socketId: string, limit: number, windowMs: number): boolean {
  const now = Date.now();
  const record = messageRates.get(socketId);
 
  if (!record || now > record.resetTime) {
    messageRates.set(socketId, { count: 1, resetTime: now + windowMs });
    return true;
  }
 
  if (record.count >= limit) {
    return false;
  }
 
  record.count++;
  return true;
}
 
// Connected users tracking
const connectedUsers = new Map<string, { userId: string; username: string; socketId: string }>();
 
chatNamespace.on("connection", (socket) => {
  const { userId, username } = socket.data;
  connectedUsers.set(socket.id, { userId, username, socketId: socket.id });
 
  // Broadcast user online status
  chatNamespace.emit("presence:online", { userId, username });
 
  // Send current online users to the new connection
  const onlineUsers = Array.from(connectedUsers.values())
    .filter((u, i, arr) => arr.findIndex((x) => x.userId === u.userId) === i)
    .map(({ userId, username }) => ({ userId, username }));
  socket.emit("presence:list", onlineUsers);
 
  // Join user's personal room
  socket.join(`user:${userId}`);
 
  // Handle joining chat rooms
  socket.on("room:join", async (roomId: string) => {
    socket.join(roomId);
    socket.to(roomId).emit("room:user-joined", { userId, username, roomId });
 
    // Send recent messages
    const messages = await getRecentMessages(roomId, 50);
    socket.emit("room:history", { roomId, messages });
  });
 
  // Handle leaving rooms
  socket.on("room:leave", (roomId: string) => {
    socket.leave(roomId);
    socket.to(roomId).emit("room:user-left", { userId, username, roomId });
  });
 
  // Handle chat messages
  socket.on("chat:message", async (data: { roomId: string; text: string; replyTo?: string }, callback) => {
    if (!rateLimit(socket.id, 10, 10000)) {
      callback?.({ status: "error", error: "Rate limit exceeded" });
      return;
    }
 
    if (!data.text?.trim() || data.text.length > 5000) {
      callback?.({ status: "error", error: "Invalid message" });
      return;
    }
 
    const message = {
      id: generateId(),
      roomId: data.roomId,
      userId,
      username,
      text: data.text.trim(),
      replyTo: data.replyTo,
      timestamp: new Date().toISOString(),
      readBy: [userId],
    };
 
    await saveMessage(message);
    chatNamespace.to(data.roomId).emit("chat:message", message);
    callback?.({ status: "ok", messageId: message.id });
  });
 
  // Handle typing indicators
  socket.on("chat:typing", (data: { roomId: string; isTyping: boolean }) => {
    socket.to(data.roomId).emit("chat:typing", {
      userId,
      username,
      roomId: data.roomId,
      isTyping: data.isTyping,
    });
  });
 
  // Handle read receipts
  socket.on("chat:read", async (data: { roomId: string; messageId: string }) => {
    await markAsRead(data.messageId, userId);
    socket.to(data.roomId).emit("chat:read", {
      userId,
      messageId: data.messageId,
      roomId: data.roomId,
    });
  });
 
  // Handle disconnection
  socket.on("disconnect", () => {
    connectedUsers.delete(socket.id);
    const stillConnected = Array.from(connectedUsers.values()).some((u) => u.userId === userId);
    if (!stillConnected) {
      chatNamespace.emit("presence:offline", { userId, username });
    }
  });
});
 
// Database helpers (MongoDB)
import mongoose from "mongoose";
 
const MessageSchema = new mongoose.Schema({
  roomId: { type: String, required: true, index: true },
  userId: { type: String, required: true },
  username: { type: String, required: true },
  text: { type: String, required: true, maxlength: 5000 },
  replyTo: { type: String },
  readBy: [{ type: String }],
  timestamp: { type: Date, default: Date.now, index: true },
});
 
MessageSchema.index({ roomId: 1, timestamp: -1 });
 
const Message = mongoose.model("Message", MessageSchema);
 
async function saveMessage(message: any) {
  return Message.create(message);
}
 
async function getRecentMessages(roomId: string, limit: number) {
  return Message.find({ roomId })
    .sort({ timestamp: -1 })
    .limit(limit)
    .lean()
    .then((msgs) => msgs.reverse());
}
 
async function markAsRead(messageId: string, userId: string) {
  return Message.updateOne({ _id: messageId }, { $addToSet: { readBy: userId } });
}
 
function generateId() {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
 
// Start server
const PORT = process.env.PORT || 3001;
 
mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost/chat").then(() => {
  httpServer.listen(PORT, () => {
    console.log(`Chat server running on port ${PORT}`);
  });
});

Client Implementation

// client/useSocket.ts
import { io, Socket } from "socket.io-client";
import { useEffect, useRef, useState, useCallback } from "react";
 
export function useSocket(token: string) {
  const socketRef = useRef<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);
 
  useEffect(() => {
    const socket = io("http://localhost:3001/chat", {
      auth: { token },
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });
 
    socketRef.current = socket;
 
    socket.on("connect", () => setIsConnected(true));
    socket.on("disconnect", () => setIsConnected(false));
    socket.on("connect_error", (err) => {
      console.error("Connection error:", err.message);
    });
 
    return () => {
      socket.disconnect();
    };
  }, [token]);
 
  return { socket: socketRef.current, isConnected };
}
// client/useChat.ts
import { useEffect, useState, useCallback, useRef } from "react";
import type { Socket } from "socket.io-client";
 
interface Message {
  id: string;
  roomId: string;
  userId: string;
  username: string;
  text: string;
  replyTo?: string;
  timestamp: string;
  readBy: string[];
}
 
interface TypingUser {
  userId: string;
  username: string;
}
 
export function useChat(socket: Socket | null, roomId: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [typingUsers, setTypingUsers] = useState<TypingUser[]>([]);
  const typingTimeoutRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
 
  useEffect(() => {
    if (!socket || !roomId) return;
 
    socket.emit("room:join", roomId);
 
    const handleMessage = (message: Message) => {
      if (message.roomId === roomId) {
        setMessages((prev) => [...prev, message]);
      }
    };
 
    const handleHistory = (data: { roomId: string; messages: Message[] }) => {
      if (data.roomId === roomId) {
        setMessages(data.messages);
      }
    };
 
    const handleTyping = (data: { userId: string; username: string; roomId: string; isTyping: boolean }) => {
      if (data.roomId !== roomId) return;
 
      if (data.isTyping) {
        setTypingUsers((prev) => {
          if (prev.some((u) => u.userId === data.userId)) return prev;
          return [...prev, { userId: data.userId, username: data.username }];
        });
 
        // Auto-remove typing indicator after 3 seconds
        const existing = typingTimeoutRef.current.get(data.userId);
        if (existing) clearTimeout(existing);
 
        const timeout = setTimeout(() => {
          setTypingUsers((prev) => prev.filter((u) => u.userId !== data.userId));
          typingTimeoutRef.current.delete(data.userId);
        }, 3000);
 
        typingTimeoutRef.current.set(data.userId, timeout);
      } else {
        setTypingUsers((prev) => prev.filter((u) => u.userId !== data.userId));
      }
    };
 
    const handleRead = (data: { userId: string; messageId: string }) => {
      setMessages((prev) =>
        prev.map((msg) =>
          msg.id === data.messageId
            ? { ...msg, readBy: [...new Set([...msg.readBy, data.userId])] }
            : msg
        )
      );
    };
 
    socket.on("chat:message", handleMessage);
    socket.on("room:history", handleHistory);
    socket.on("chat:typing", handleTyping);
    socket.on("chat:read", handleRead);
 
    return () => {
      socket.emit("room:leave", roomId);
      socket.off("chat:message", handleMessage);
      socket.off("room:history", handleHistory);
      socket.off("chat:typing", handleTyping);
      socket.off("chat:read", handleRead);
    };
  }, [socket, roomId]);
 
  const sendMessage = useCallback(
    (text: string, replyTo?: string) => {
      if (!socket || !text.trim()) return;
 
      socket.emit("chat:message", { roomId, text, replyTo }, (response: any) => {
        if (response.status === "error") {
          console.error("Failed to send message:", response.error);
        }
      });
    },
    [socket, roomId]
  );
 
  const sendTyping = useCallback(
    (isTyping: boolean) => {
      if (!socket) return;
      socket.emit("chat:typing", { roomId, isTyping });
    },
    [socket, roomId]
  );
 
  const markRead = useCallback(
    (messageId: string) => {
      if (!socket) return;
      socket.emit("chat:read", { roomId, messageId });
    },
    [socket, roomId]
  );
 
  return { messages, typingUsers, sendMessage, sendTyping, markRead };
}

Chat interface

Real-World Use Cases

Customer Support Chat

A SaaS company built a customer support widget using Socket.IO. Support agents join customer-specific rooms and receive real-time messages. The system uses namespaces to separate customer-facing and agent-facing connections, with different authentication and rate limiting for each.

Multiplayer Game Lobby

A gaming platform uses Socket.IO rooms for matchmaking. Players join a lobby room, and when enough players are present, the server creates a game room and moves all players into it. The lobby room broadcasts real-time player counts and status updates.

Collaborative Document Editing

A document editing tool uses Socket.IO to broadcast cursor positions and text changes. Each document is a room, and changes are broadcast to all connected editors. The server resolves conflicts using operational transformation.

Live Event Streaming

A live event platform uses Socket.IO to broadcast real-time updates to thousands of viewers. The server uses Socket.IO's Redis adapter to distribute messages across multiple server instances.

Best Practices for Production

  1. Use namespaces for logical separation — Don't create separate Socket.IO servers for different features. Use namespaces to multiplex different event domains over a single connection.

  2. Implement rate limiting on the server — Prevent abuse by limiting the number of messages per socket per time window. Use a sliding window algorithm for fairness.

  3. Persist messages asynchronously — Don't block message delivery on database writes. Emit the message immediately, then persist it in the background. If persistence fails, the message is already delivered (and you can retry).

  4. Use acknowledgments for critical messages — For messages that must be confirmed (e.g., payment notifications, transaction confirmations), use Socket.IO acknowledgments to verify delivery.

  5. Implement reconnection handling — Socket.IO handles reconnection automatically, but your application needs to handle the state resync. Send the last received message ID on reconnect and fetch any missed messages.

  6. Use the Redis adapter for horizontal scaling — When running multiple server instances, the Redis adapter broadcasts messages across all instances. This is essential for production deployments.

  7. Monitor connection health — Track connected users, message throughput, and error rates. Socket.IO's instrument() function provides a built-in admin dashboard.

  8. Secure your WebSocket connections — Use WSS (WebSocket Secure) in production, authenticate connections during the handshake, and validate all incoming messages.

Common Pitfalls and Solutions

PitfallImpactSolution
No message persistenceMessages lost on refreshStore messages in a database and replay on room join
Missing rate limitingSpam, DoS vulnerabilityImplement per-socket rate limiting in middleware
No reconnection state syncMissing messages after reconnectTrack last message ID and fetch missed messages
Memory leaks from event listenersServer crashClean up listeners on disconnect
Broadcasting to all socketsPerformance degradationUse rooms and namespaces to scope broadcasts
No horizontal scalingSingle server bottleneckUse Redis adapter for multi-instance deployments

Performance Optimization

Message Batching

For high-throughput scenarios, batch messages before broadcasting:

const messageBatch: Message[] = [];
const BATCH_INTERVAL = 50; // ms
 
setInterval(() => {
  if (messageBatch.length > 0) {
    chatNamespace.to("room").emit("chat:batch", messageBatch);
    messageBatch.length = 0;
  }
}, BATCH_INTERVAL);
 
// Instead of broadcasting each message individually
messageBatch.push(message);

Connection Pooling

// Use sticky sessions with a load balancer
// Nginx configuration for Socket.IO
// upstream chat_servers {
//   ip_hash;  # Sticky sessions
//   server chat1:3001;
//   server chat2:3001;
//   server chat3:3001;
// }

Redis Adapter for Scaling

import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
 
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
 
await Promise.all([pubClient.connect(), subClient.connect()]);
 
io.adapter(createAdapter(pubClient, subClient));

Comparison with Alternatives

FeatureSocket.IOWebSocket (raw)SSEPusher/Ably
Automatic reconnectionBuilt-inManualBuilt-inBuilt-in
Room managementBuilt-inManualN/ABuilt-in
Fallback transportHTTP long-pollingNoneN/AHTTP
Binary supportBuilt-inManualNoBuilt-in
Admin dashboardBuilt-inNoNoDashboard
Self-hostedYesYesYesNo
Scaling complexityRedis adapterManualManualManaged

Advanced Patterns

Message Encryption

import crypto from "crypto";
 
const ENCRYPTION_KEY = process.env.MESSAGE_ENCRYPTION_KEY!;
 
function encryptMessage(text: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(ENCRYPTION_KEY, "hex"), iv);
  let encrypted = cipher.update(text, "utf8", "hex");
  encrypted += cipher.final("hex");
  return `${iv.toString("hex")}:${encrypted}`;
}
 
function decryptMessage(encrypted: string): string {
  const [ivHex, encryptedText] = encrypted.split(":");
  const decipher = crypto.createDecipheriv(
    "aes-256-cbc",
    Buffer.from(ENCRYPTION_KEY, "hex"),
    Buffer.from(ivHex, "hex")
  );
  let decrypted = decipher.update(encryptedText, "hex", "utf8");
  decrypted += decipher.final("utf8");
  return decrypted;
}

File Sharing

socket.on("chat:file", async (data: { roomId: string; file: Buffer; fileName: string; mimeType: string }) => {
  const url = await uploadToStorage(data.file, data.fileName);
  const message = {
    id: generateId(),
    roomId: data.roomId,
    userId: socket.data.userId,
    username: socket.data.username,
    text: "",
    attachment: { url, fileName: data.fileName, mimeType: data.mimeType },
    timestamp: new Date().toISOString(),
  };
  await saveMessage(message);
  chatNamespace.to(data.roomId).emit("chat:message", message);
});

Testing Strategies

import { createServer } from "http";
import { Server } from "socket.io";
import { io as Client } from "socket.io-client";
 
describe("Chat Server", () => {
  let io: Server, clientSocket: any;
 
  beforeAll((done) => {
    const httpServer = createServer();
    io = new Server(httpServer);
    httpServer.listen(() => {
      const port = (httpServer.address() as any).port;
      clientSocket = Client(`http://localhost:${port}/chat`, {
        auth: { token: generateTestToken() },
      });
      clientSocket.on("connect", done);
    });
  });
 
  afterAll(() => {
    io.close();
    clientSocket.disconnect();
  });
 
  it("should join a room and receive history", (done) => {
    clientSocket.emit("room:join", "test-room");
    clientSocket.on("room:history", (data: any) => {
      expect(data.roomId).toBe("test-room");
      expect(Array.isArray(data.messages)).toBe(true);
      done();
    });
  });
 
  it("should broadcast messages to room", (done) => {
    clientSocket.emit("room:join", "broadcast-room");
    clientSocket.emit("chat:message", { roomId: "broadcast-room", text: "Hello!" });
    clientSocket.on("chat:message", (msg: any) => {
      expect(msg.text).toBe("Hello!");
      done();
    });
  });
});

Future Outlook

Socket.IO continues to evolve with the web platform. Version 5 introduced ESM support, improved TypeScript definitions, and better connection state recovery. The roadmap includes WebTransport support (the successor to WebSocket that supports unreliable datagrams), improved cluster mode, and first-class support for serverless environments.

The real-time web is expanding beyond chat. Live collaboration, multiplayer games, real-time analytics dashboards, and IoT device management all use WebSocket-based communication. Socket.IO's abstractions—rooms, namespaces, acknowledgments—remain relevant as the use cases diversify.

Conclusion

Socket.IO is the most mature and feature-complete real-time communication library for Node.js. The key takeaways:

  1. Use namespaces for logical separation — Multiplex different features over a single connection
  2. Rooms are the core abstraction — Every chat, notification channel, or collaborative session maps to a room
  3. Persist messages asynchronously — Deliver immediately, store in the background
  4. Scale with Redis adapter — Essential for multi-instance production deployments
  5. Handle reconnection gracefully — Sync state on reconnect, don't just reconnect the socket

Start with a minimal server that handles connection, message, and disconnect events. Add rooms, typing indicators, and persistence incrementally. The Socket.IO API is intuitive enough that a working prototype takes hours, while the scaling and persistence concerns give you weeks of optimization work.