Real-time features — live notifications, collaborative editing, presence indicators, and chat — are no longer optional for modern web applications. Socket.io sits on top of WebSockets and provides an elegant abstraction that works across every browser, falling back gracefully where native WebSockets are unavailable.
mern-realtime/
server/ ← Express + Socket.io
index.js
events/
chat.js
notifications.js
client/ ← React + Socket.io-client
src/
hooks/
useSocket.js
components/
ChatRoom.jsx
NotificationBell.jsx
// server/index.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
methods: ['GET', 'POST'],
},
});
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
socket.user = verifyJWT(token);
next();
} catch (e) {
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
console.log(`User connected: ${socket.user.id}`);
socket.on('join-room', (roomId) => {
socket.join(roomId);
socket.to(roomId).emit('user-joined', { userId: socket.user.id });
});
socket.on('send-message', ({ roomId, text }) => {
const message = { id: uuid(), userId: socket.user.id, text, timestamp: Date.now() };
io.to(roomId).emit('new-message', message);
// Persist to MongoDB
Message.create({ ...message, room: roomId });
});
socket.on('disconnect', () => {
console.log(`User disconnected: ${socket.user.id}`);
});
});
httpServer.listen(4000);
// client/src/hooks/useSocket.js
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';
export function useSocket(token) {
const socketRef = useRef(null);
useEffect(() => {
socketRef.current = io(process.env.REACT_APP_API_URL, {
auth: { token },
});
return () => {
socketRef.current?.disconnect();
};
}, [token]);
return socketRef.current;
}
Track who is online by maintaining a Map of connected user IDs on the server:
const onlineUsers = new Map(); // userId → socketId
io.on('connection', (socket) => {
onlineUsers.set(socket.user.id, socket.id);
io.emit('online-users', [...onlineUsers.keys()]);
socket.on('disconnect', () => {
onlineUsers.delete(socket.user.id);
io.emit('online-users', [...onlineUsers.keys()]);
});
});
Once you add a second server instance, in-memory state no longer works. Switch to the Socket.io Redis Adapter so events are broadcast across all instances:
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
With this setup your application scales horizontally behind a load balancer with no code changes to your event handlers.