Node.jsBackendFull-Stack

Build a Chat App with Socket.IO in Node.js

TT
TopicTrick Team
Build a Chat App with Socket.IO in Node.js

Build a Chat App with Socket.IO in Node.js

A chat application is the canonical real-time project. It exercises every real-time concern simultaneously: rooms, presence, typing indicators, message history, authentication, and reconnection. Build it correctly and you understand the patterns that apply to every other real-time feature.

This module builds a complete group chat system with Socket.IO, MongoDB for message persistence, and JWT authentication.

This is Module 26 of the Node.js Full‑Stack Developer course.


Installing Socket.IO

bash
npm install socket.io                  # server
npm install socket.io-client           # if React is in a separate package

If React is served from the same Express server, the browser can load the client from the Socket.IO server directly — no separate install needed.


Message Model

js
// features/chat/message.model.js
import mongoose from 'mongoose';

const messageSchema = new mongoose.Schema(
  {
    roomId:  { type: String, required: true, index: true },
    content: { type: String, required: true, maxlength: 2000 },
    type:    { type: String, enum: ['text', 'image', 'system'], default: 'text' },
    sender: {
      userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
      name:   { type: String, required: true },   // denormalised for fast reads
      avatar: String,
    },
  },
  { timestamps: true }
);

// Fetch last N messages efficiently
messageSchema.index({ roomId: 1, createdAt: -1 });

export const Message = mongoose.model('Message', messageSchema);

Setting Up Socket.IO with Express

js
// server.js
import http from 'http';
import { Server } from 'socket.io';
import app from './app.js';
import { setupChatHandlers } from './features/chat/chat.handlers.js';

const server = http.createServer(app);

const io = new Server(server, {
  cors: {
    origin: process.env.FRONTEND_URL,
    credentials: true,
  },
  pingTimeout: 60_000,
  pingInterval: 25_000,
});

// Auth middleware — runs before every connection
io.use(async (socket, next) => {
  const token = socket.handshake.auth?.token;
  if (!token) return next(new Error('Authentication required'));

  try {
    const { verifyAccessToken } = await import('./lib/tokens.js');
    const user = verifyAccessToken(token);
    socket.data.user = user;  // attach to socket for use in handlers
    next();
  } catch {
    next(new Error('Invalid or expired token'));
  }
});

io.on('connection', (socket) => {
  setupChatHandlers(io, socket);
});

server.listen(process.env.PORT ?? 3000);

Chat Event Handlers

js
// features/chat/chat.handlers.js
import { Message } from './message.model.js';

// Track online users per room: roomId → Map<userId, { name, avatar, socketId }>
const roomPresence = new Map();

export function setupChatHandlers(io, socket) {
  const { sub: userId, name, avatar } = socket.data.user ?? {};

  // ── Join a room ─────────────────────────────────────────────────────────────
  socket.on('room:join', async ({ roomId }, ack) => {
    try {
      await socket.join(roomId);

      // Update presence
      if (!roomPresence.has(roomId)) roomPresence.set(roomId, new Map());
      roomPresence.get(roomId).set(userId, { name, avatar, socketId: socket.id });

      // Notify others in the room
      socket.to(roomId).emit('room:user_joined', { userId, name, avatar });

      // Send online users list to the joining socket
      const online = [...roomPresence.get(roomId).values()];
      socket.emit('room:presence', { roomId, online });

      // Send recent message history
      const history = await Message.find({ roomId })
        .sort({ createdAt: -1 })
        .limit(50)
        .lean();
      socket.emit('room:history', { roomId, messages: history.reverse() });

      ack?.({ success: true });
    } catch (err) {
      ack?.({ error: err.message });
    }
  });

  // ── Leave a room ─────────────────────────────────────────────────────────────
  socket.on('room:leave', ({ roomId }) => {
    socket.leave(roomId);
    roomPresence.get(roomId)?.delete(userId);
    if (roomPresence.get(roomId)?.size === 0) roomPresence.delete(roomId);
    socket.to(roomId).emit('room:user_left', { userId, name });
  });

  // ── Send a message ───────────────────────────────────────────────────────────
  socket.on('message:send', async ({ roomId, content }, ack) => {
    if (!content?.trim()) return ack?.({ error: 'Empty message' });
    if (content.length > 2000) return ack?.({ error: 'Message too long' });

    try {
      // Check the socket is in the room
      if (!socket.rooms.has(roomId)) {
        return ack?.({ error: 'Not in room' });
      }

      // Persist to database
      const message = await Message.create({
        roomId,
        content: content.trim(),
        sender: { userId, name, avatar },
      });

      const payload = message.toObject();

      // Broadcast to everyone in the room (including sender)
      io.to(roomId).emit('message:new', payload);

      ack?.({ success: true, messageId: message._id });
    } catch (err) {
      ack?.({ error: 'Failed to send message' });
    }
  });

  // ── Typing indicators ────────────────────────────────────────────────────────
  socket.on('typing:start', ({ roomId }) => {
    socket.to(roomId).emit('typing:start', { userId, name });
  });

  socket.on('typing:stop', ({ roomId }) => {
    socket.to(roomId).emit('typing:stop', { userId });
  });

  // ── Disconnect cleanup ───────────────────────────────────────────────────────
  socket.on('disconnect', () => {
    // Remove from all rooms' presence maps
    for (const [roomId, users] of roomPresence.entries()) {
      if (users.has(userId)) {
        users.delete(userId);
        socket.to(roomId).emit('room:user_left', { userId, name });
        if (users.size === 0) roomPresence.delete(roomId);
      }
    }
  });
}

REST API — Room History Endpoint

Socket.IO handles real-time events, but fetching message history on page load is better done via REST:

js
// features/chat/chat.router.js
import express from 'express';
import { requireAuth } from '../../middleware/auth.js';
import { Message } from './message.model.js';

const router = express.Router();

// GET /api/v1/rooms/:roomId/messages?before=<timestamp>&limit=50
router.get('/:roomId/messages', requireAuth, async (req, res, next) => {
  try {
    const { before, limit = 50 } = req.query;
    const query = { roomId: req.params.roomId };
    if (before) query.createdAt = { $lt: new Date(before) };

    const messages = await Message.find(query)
      .sort({ createdAt: -1 })
      .limit(Math.min(100, +limit))
      .lean();

    res.json({ messages: messages.reverse() });
  } catch (err) {
    next(err);
  }
});

export default router;

React Client

jsx
// client/src/hooks/useChat.js
import { useEffect, useRef, useState, useCallback } from 'react';
import { io } from 'socket.io-client';
import { getAccessToken } from '../lib/api';

export function useChat(roomId) {
  const socketRef = useRef(null);
  const [messages, setMessages]   = useState([]);
  const [online, setOnline]       = useState([]);
  const [typing, setTyping]       = useState([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const socket = io({
      auth: { token: getAccessToken() },
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });

    socketRef.current = socket;

    socket.on('connect',    () => setConnected(true));
    socket.on('disconnect', () => setConnected(false));

    socket.on('room:history',  ({ messages }) => setMessages(messages));
    socket.on('room:presence', ({ online })   => setOnline(online));
    socket.on('message:new',   (msg)          => setMessages(prev => [...prev, msg]));

    socket.on('room:user_joined', ({ userId, name, avatar }) =>
      setOnline(prev => [...prev.filter(u => u.userId !== userId), { userId, name, avatar }])
    );
    socket.on('room:user_left',   ({ userId }) =>
      setOnline(prev => prev.filter(u => u.userId !== userId))
    );

    socket.on('typing:start', ({ userId, name }) =>
      setTyping(prev => prev.some(t => t.userId === userId) ? prev : [...prev, { userId, name }])
    );
    socket.on('typing:stop', ({ userId }) =>
      setTyping(prev => prev.filter(t => t.userId !== userId))
    );

    // Join room after connecting
    socket.emit('room:join', { roomId });

    return () => {
      socket.emit('room:leave', { roomId });
      socket.disconnect();
    };
  }, [roomId]);

  const sendMessage = useCallback((content) => {
    socketRef.current?.emit('message:send', { roomId, content });
  }, [roomId]);

  const startTyping = useCallback(() => {
    socketRef.current?.emit('typing:start', { roomId });
  }, [roomId]);

  const stopTyping = useCallback(() => {
    socketRef.current?.emit('typing:stop', { roomId });
  }, [roomId]);

  return { messages, online, typing, connected, sendMessage, startTyping, stopTyping };
}
jsx
// client/src/pages/ChatPage.jsx
import { useState, useRef, useEffect } from 'react';
import { useChat } from '../hooks/useChat';

export function ChatPage({ roomId }) {
  const { messages, online, typing, connected, sendMessage, startTyping, stopTyping } = useChat(roomId);
  const [input, setInput] = useState('');
  const typingTimeout = useRef(null);
  const bottomRef = useRef(null);

  // Auto-scroll to bottom on new message
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  function handleInput(e) {
    setInput(e.target.value);
    startTyping();
    clearTimeout(typingTimeout.current);
    typingTimeout.current = setTimeout(stopTyping, 2000);
  }

  function handleSend(e) {
    e.preventDefault();
    if (!input.trim()) return;
    sendMessage(input.trim());
    setInput('');
    stopTyping();
  }

  return (
    <div className="flex flex-col h-screen">
      {/* Online users */}
      <div className="p-2 border-b">
        {online.length} online · {connected ? '🟢 Connected' : '🔴 Disconnected'}
      </div>

      {/* Messages */}
      <div className="flex-1 overflow-y-auto p-4 space-y-2">
        {messages.map(msg => (
          <div key={msg._id}>
            <span className="font-bold">{msg.sender.name}</span>
            <span className="ml-2">{msg.content}</span>
            <span className="ml-2 text-xs text-gray-400">
              {new Date(msg.createdAt).toLocaleTimeString()}
            </span>
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      {/* Typing indicator */}
      {typing.length > 0 && (
        <div className="px-4 py-1 text-sm text-gray-500 italic">
          {typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing...
        </div>
      )}

      {/* Input */}
      <form onSubmit={handleSend} className="p-4 border-t flex gap-2">
        <input
          value={input}
          onChange={handleInput}
          placeholder="Type a message..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button type="submit" disabled={!input.trim()}>Send</button>
      </form>
    </div>
  );
}

Scaling with Redis Adapter

For multiple server instances:

bash
npm install @socket.io/redis-adapter
js
// server.js
import { createAdapter } from '@socket.io/redis-adapter';
import redis from './lib/redis.js';

const pubClient = redis;
const subClient = redis.duplicate();

io.adapter(createAdapter(pubClient, subClient));

Now any instance can emit to any room — Redis routes the message to the right instance.


Node.js Full‑Stack Course — Module 26 of 32

You can now build any real-time feature with Socket.IO. Continue to Module 27 to learn testing with Jest and Supertest.


    Summary

    Socket.IO makes real-time group features straightforward:

    • Attach new Server(httpServer) to the same HTTP server as Express — one port for everything
    • Use Socket.IO middleware (io.use) to authenticate connections before the connection event fires
    • Rooms (socket.join(roomId), io.to(roomId).emit()) handle group broadcasting with zero manual tracking
    • Persist messages to MongoDB in the event handler; send history to newly-joined sockets on room:join
    • Presence (online users) is tracked in a server-side Map — update on join, leave, and disconnect
    • Typing indicators use brief, debounced typing:start / typing:stop events
    • Use acknowledgement callbacks (the third argument to emit) to confirm delivery of important events
    • Scale across instances with @socket.io/redis-adapter — two lines of code

    Continue to Module 27: Testing with Jest & Supertest →