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
npm install socket.io # server
npm install socket.io-client # if React is in a separate packageIf 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
// 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
// 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
// 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:
// 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
// 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 };
}// 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:
npm install @socket.io/redis-adapter// 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 theconnectionevent 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:stopevents - 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 →
