Rooms, Namespaces and Custom Events

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
Note: Socket.io rooms are server-side constructs — they exist only in the Socket.io server’s memory and are automatically cleaned up when all sockets leave them. Room names are arbitrary strings — the convention '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.
Tip: You can get the current room members count with 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.
Warning: Socket.io rooms do not persist across server restarts. If your Express server restarts while clients are connected, all room memberships are lost and clients must reconnect and re-join rooms. Socket.io handles reconnection automatically on the client side, but your client code must re-emit the join event on reconnect. Listen for the socket’s 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

🧠 Test Yourself

User A and User B both have Post 123 open. User A posts a comment via the REST API. The comment controller emits io.to('post:123').emit('new-comment', comment). What happens on User A’s screen and User B’s screen?