Socket.io’s room system is what makes real-time features scalable — instead of broadcasting every event to every connected client, you scope events to a named room that only interested clients join. For the MERN Blog’s comment system, each post gets its own room. When a user opens a post, the client joins the room for that post. When a new comment is posted, the server emits it only to that room. When the user navigates away, the client leaves the room. In this lesson you will implement the complete rooms-based comment system: client join/leave events, server-side room management, and targeted broadcasts from the comment creation endpoint.
The Room Lifecycle for a Blog Post
User opens /posts/64a1f2b3:
Client emits: 'join-post' { postId: '64a1f2b3' }
Server: socket.join('post:64a1f2b3')
Server emits back: 'joined-post' { postId, memberCount }
Client: listening for 'new-comment' events
Another user posts a comment via POST /api/comments:
Controller: creates comment in MongoDB
Controller: io.to('post:64a1f2b3').emit('new-comment', comment)
ALL clients in room 'post:64a1f2b3' receive the comment instantly
User navigates away from the post:
Client emits: 'leave-post' { postId: '64a1f2b3' }
Server: socket.leave('post:64a1f2b3')
User no longer receives events for that post
'post:64a1f2b3' (resource type + ID) is clear and prevents name collisions across different resources. A socket can be in multiple rooms simultaneously — useful if a page shows multiple live-updating resources.io.to(room).fetchSockets() — useful for showing “3 people reading this post” presence indicators. However, this is an async operation and should be used sparingly. For a simpler presence count, maintain your own in-memory Map on the server that increments on join and decrements on leave.connect event (which fires on both initial connection and reconnection) and join the relevant room there.Server-Side Room Handling
// server/src/config/socket.js — room event handlers
const setupSocketHandlers = (io) => {
io.on('connection', (socket) => {
console.log(`Socket connected: ${socket.id}`);
// ── Join a post room ───────────────────────────────────────────────────
socket.on('join-post', async ({ postId }) => {
if (!postId) return;
const room = `post:${postId}`;
await socket.join(room);
// Count active viewers in this room
const sockets = await io.in(room).fetchSockets();
const count = sockets.length;
// Confirm to this client
socket.emit('joined-post', { postId, viewerCount: count });
// Notify others in the room that someone joined
socket.to(room).emit('viewer-joined', { viewerCount: count });
});
// ── Leave a post room ─────────────────────────────────────────────────
socket.on('leave-post', async ({ postId }) => {
if (!postId) return;
const room = `post:${postId}`;
await socket.leave(room);
const sockets = await io.in(room).fetchSockets();
const count = sockets.length;
// Notify remaining viewers
io.to(room).emit('viewer-left', { viewerCount: count });
});
// ── Typing indicator ──────────────────────────────────────────────────
socket.on('typing', ({ postId, userName, isTyping }) => {
// Broadcast to room excluding the sender
socket.to(`post:${postId}`).emit('user-typing', { userName, isTyping });
});
// ── Clean up on disconnect ────────────────────────────────────────────
socket.on('disconnect', () => {
console.log(`Socket disconnected: ${socket.id}`);
// Socket.io automatically removes the socket from all rooms on disconnect
});
});
};
module.exports = setupSocketHandlers;
Emitting from the Comment Controller
// server/src/controllers/commentController.js
const Comment = require('../models/Comment');
const asyncHandler = require('../utils/asyncHandler');
const { getIO } = require('../config/socket');
// @desc Create a comment
// @route POST /api/posts/:postId/comments
// @access Protected
const createComment = asyncHandler(async (req, res) => {
const { body } = req.body;
const { postId } = req.params;
const comment = await Comment.create({
body,
post: postId,
author: req.user._id,
});
// Populate author for the real-time event payload
await comment.populate('author', 'name avatar');
// Respond to the client who posted the comment
res.status(201).json({ success: true, data: comment });
// Emit to all OTHER viewers of this post via Socket.io
// (the comment poster will add it to their own UI via the API response)
getIO()
.to(`post:${postId}`)
.emit('new-comment', {
comment,
postId,
});
});
module.exports = { createComment };
Namespaces — Organising Socket Events
// Namespaces separate concerns — like mounting Express routers
// Default namespace: '/' (what we use above)
// Custom namespaces: '/comments', '/notifications'
// Create a comments namespace
const commentsNS = io.of('/comments');
commentsNS.on('connection', (socket) => {
socket.on('join', ({ postId }) => socket.join(`post:${postId}`));
// All comment events scoped here
});
// Create a notifications namespace
const notificationsNS = io.of('/notifications');
notificationsNS.on('connection', (socket) => {
// All notification events scoped here
});
// Client connects to a specific namespace:
// const commentSocket = io('http://localhost:5000/comments');
// const notifSocket = io('http://localhost:5000/notifications');
Common Mistakes
Mistake 1 — Not re-joining rooms on reconnect
❌ Wrong — room joined only once at page load:
useEffect(() => {
socket.emit('join-post', { postId }); // joined on mount
// If socket disconnects and reconnects — room membership lost!
}, [postId]);
✅ Correct — re-join on every connection (fires on initial connect AND reconnect):
useEffect(() => {
const rejoin = () => socket.emit('join-post', { postId });
socket.on('connect', rejoin);
if (socket.connected) rejoin(); // join immediately if already connected
return () => {
socket.off('connect', rejoin);
socket.emit('leave-post', { postId });
};
}, [socket, postId]);
Mistake 2 — Using io.emit() instead of io.to(room).emit() for post events
❌ Wrong — all 500 connected users receive the comment for post X:
io.emit('new-comment', comment); // everyone sees every comment!
✅ Correct — only viewers of that post receive it:
io.to(`post:${postId}`).emit('new-comment', comment); // ✓
Mistake 3 — Emitting the new comment back to the poster (double display)
❌ Wrong — poster sees their own comment twice (once from API response, once from socket):
io.to(`post:${postId}`).emit('new-comment', comment);
// The poster is in the room — they receive this too + they get the API response
✅ Correct — exclude the sender using socket.to() instead of io.to():
// In a socket-aware context:
socket.to(`post:${postId}`).emit('new-comment', comment); // excludes sender ✓
// Or from a REST controller where you do not have the socket:
// Let the API response handle the sender; socket handles everyone else
Quick Reference
| Task | Server Code |
|---|---|
| Join room | socket.join('post:id') |
| Leave room | socket.leave('post:id') |
| Emit to room | io.to('post:id').emit('event', data) |
| Emit to room (excl. sender) | socket.to('post:id').emit('event', data) |
| Count room members | (await io.in('post:id').fetchSockets()).length |
| Get all rooms of socket | socket.rooms — Set of room names |