6 Topics
Chapter•50 Minutes

Implement Socket.IO Rooms and Private Messaging

Create group conversations using rooms, join and leave rooms, broadcast room-specific messages, and implement private user-to-user communication.

What Are Socket.IO Rooms?

A room is a logical grouping of Socket.IO connections.

Rooms allow you to send events to a selected group of connected clients rather than broadcasting to every connected user.

Imagine a collaboration application with three projects:

  • Project A.
  • Project B.
  • Project C.

Users working on Project A should receive messages belonging to Project A only.

Instead of sending messages to every connected client, users join a room associated with their project.

A socket can join multiple rooms simultaneously.

Socket.IO automatically creates a private room for every socket using its socket ID. This allows you to target an individual connection using socket.id.

Rooms are managed on the server side and are not a substitute for application authorization. A user should only be allowed to join a room after the server verifies their permission.

Room-Based Communication
Key Takeaways
  • Rooms group connected sockets logically.

  • A socket can join multiple rooms.

  • Room-based broadcasting targets selected users.

Join a Room

Socket.IO provides socket.join(roomName) to add a socket to a room.

The method returns a Promise, so it can be awaited.

For our chat application, users will join a room by emitting the room:join event.

We will validate the room name before joining it.

Room Authorization

Validating a room name does not prove that the user has permission to access it. In production, verify membership and authorization before joining private rooms.

Join Room Event
socket.on('room:join', async (payload, acknowledge) => {
  const room = payload?.room;

  if (typeof room !== 'string' || !/^[a-zA-Z0-9_-]{1,50}$/.test(room)) {
    acknowledge?.({
      success: false,
      error: 'Invalid room name'
    });
    return;
  }

  await socket.join(room);

  acknowledge?.({
    success: true,
    room
  });
});

The server validates the room identifier, joins the room, and acknowledges the result.

Key Takeaways
  • socket.join() adds a connection to a room.

  • Room joining can be awaited.

  • Private rooms require server-side authorization.

Leave a Room

A connected socket can leave a room using socket.leave(roomName).

This is useful when a user switches conversations, exits a project, or closes a group chat.

The server should validate the room identifier before removing the socket from the room.

Leave Room Event
socket.on('room:leave', async (payload, acknowledge) => {
  const room = payload?.room;

  if (typeof room !== 'string') {
    acknowledge?.({
      success: false,
      error: 'Invalid room'
    });
    return;
  }

  await socket.leave(room);

  acknowledge?.({
    success: true,
    room
  });
});

The socket leaves the requested room and receives an acknowledgement confirming the operation.

Key Takeaways
  • socket.leave() removes a socket from a room.

  • A socket remains connected after leaving a room.

  • Room membership and connection lifecycle are separate concepts.

Broadcast Messages to a Room

After users join rooms, messages can be sent only to members of a particular room.

Socket.IO provides the io.to(room).emit() method.

This sends an event to all sockets in the selected room, including the sender if the sender belongs to that room.

The socket.to(room).emit() method sends an event to other sockets in that room, excluding the current sender.

Room Broadcasting Methods

io.to(room).emit()

All room members

Includes the sender when the sender is a room member.

socket.to(room).emit()

Other room members

Excludes the current socket.

socket.rooms

Current membership

Contains the rooms joined by the current socket.

Room-Based Message Broadcasting
socket.on('room:message', (payload, acknowledge) => {
  const room = payload?.room;
  const message = payload?.message;

  if (
    typeof room !== 'string' ||
    typeof message !== 'string' ||
    message.trim().length === 0 ||
    message.length > 1000
  ) {
    acknowledge?.({
      success: false,
      error: 'Invalid message'
    });
    return;
  }

  if (!socket.rooms.has(room)) {
    acknowledge?.({
      success: false,
      error: 'Join the room before sending messages'
    });
    return;
  }

  io.to(room).emit('room:message', {
    room,
    message: message.trim(),
    timestamp: new Date().toISOString()
  });

  acknowledge?.({ success: true });
});

The server checks room membership, validates the message, broadcasts it to the room, and acknowledges the sender.

Key Takeaways
  • io.to(room).emit() targets a specific room.

  • socket.to(room).emit() excludes the current sender.

  • Validate room membership before processing room-specific events.

Implement Private Messaging Using Socket IDs

Socket.IO automatically creates a private room for each connected socket using its socket ID.

This means you can send an event to one specific connection using:

io.to(socketId).emit('private:message', payload);

However, socket IDs are temporary connection identifiers. They can change after reconnection and should not be treated as permanent user IDs.

For a basic demonstration, we can maintain a mapping between usernames and socket IDs.

In a production application, use authenticated user IDs and support multiple active connections per user.

Important Production Limitation

Do not trust a client-provided username as an authenticated identity. Use a verified user ID from server-side authentication. Also support multiple sockets per user because a user may have multiple browser tabs or devices connected.

Basic Private Messaging
const onlineUsers = new Map();

io.on('connection', (socket) => {
  socket.on('user:register', (username) => {
    if (
      typeof username !== 'string' ||
      username.trim().length === 0 ||
      username.length > 30
    ) return;

    const normalizedUsername = username.trim().toLowerCase();
    onlineUsers.set(normalizedUsername, socket.id);

    socket.data.username = normalizedUsername;
  });

  socket.on('private:message', (payload, acknowledge) => {
    const recipient = payload?.recipient;
    const message = payload?.message;

    if (
      typeof recipient !== 'string' ||
      typeof message !== 'string' ||
      message.trim().length === 0 ||
      message.length > 1000
    ) {
      acknowledge?.({ success: false, error: 'Invalid message' });
      return;
    }

    const recipientSocketId = onlineUsers.get(recipient.toLowerCase());

    if (!recipientSocketId) {
      acknowledge?.({ success: false, error: 'User is offline' });
      return;
    }

    io.to(recipientSocketId).emit('private:message', {
      sender: socket.data.username,
      message: message.trim(),
      timestamp: new Date().toISOString()
    });

    acknowledge?.({ success: true });
  });

  socket.on('disconnect', () => {
    if (socket.data.username) {
      onlineUsers.delete(socket.data.username);
    }
  });
});

This demonstration maintains a username-to-socket mapping and uses the recipient socket ID to deliver private messages. It supports only one connection per username and is not a production identity system.

Key Takeaways
  • Socket IDs can be used to target individual connections.

  • Socket IDs are not permanent user identities.

  • Production messaging should use authenticated user IDs.

Support Multiple Connections Per User

A real user may connect through multiple devices or browser tabs.

For example, the same user might be connected through:

  • A desktop browser.
  • A mobile browser.
  • A second desktop tab.

Maintaining only one socket ID per user means a new connection can overwrite an existing connection.

A better approach is to use a user-specific room.

When an authenticated user connects, join a room named using their permanent user ID.

For example:

user:12345

Now all connections belonging to that user can receive the same private notification.

User-Specific Room
io.on('connection', async (socket) => {
  const userId = socket.data.userId;

  if (!userId) return;

  await socket.join(`user:${userId}`);

  console.log(`User ${userId} connected`);
});

function sendPrivateNotification(io, userId, notification) {
  io.to(`user:${userId}`).emit('notification:new', notification);
}

All sockets belonging to the same authenticated user join a shared user-specific room. Sending an event to that room reaches all active connections belonging to the user.

Key Takeaways
  • A single user can have multiple active sockets.

  • User-specific rooms simplify multi-device messaging.

  • Use stable authenticated user IDs rather than socket IDs.