PHASE 4 — Topic 16: Broadcasting Messages to Everyone, or to Specific Users/Rooms

We've used several broadcasting patterns already across earlier posts. This post pulls everything together into one clear reference, and adds a few useful patterns we haven't covered yet.

The Complete Emit Cheatsheet

Here is every major way to send an event, all in one place:


    io.on("connection", (socket) => {
        // to this one client only
        socket.emit("event", data);

        // to everyone except this client
        socket.broadcast.emit("event", data);

        // to everyone, including this client
        io.emit("event", data);

        // to everyone in room1, except this client
        socket.to("room1").emit("event", data);

        // to everyone in room1, including this client
        io.to("room1").emit("event", data);

        // to everyone in room1 or room2, except this client
        socket.to(["room1", "room2"]).emit("event", data);

        // to everyone, except those in room1
        io.except("room1").emit("event", data);

        // to a specific client by socket id (private message)
        io.to(socketId).emit("event", data);
    });

to() and in() are exact aliases of each other, they behave identically. Most codebases just pick one and stick with it for consistency, to() tends to read more naturally.

A Warning Worth Remembering

You might expect socket.to(socket.id).emit(...) to work as a private message to yourself, but it does not. Every socket automatically has a room named after its own socket.id, and socket.to(...) explicitly excludes the sender. So socket.to(socket.id) targets that room while excluding the one socket in it, meaning nobody receives it. For sending an event only to yourself, always use plain socket.emit(...) instead.

Excluding a Room With .except()

Sometimes you want to reach almost everyone, except one specific group. .except() handles this cleanly:


    io.except("banned-users").emit("announcement", data);

You can also chain multiple .except() calls, or pass an array of rooms to exclude several at once:


    io.to(["room1", "room2"]).except("room3").emit("event", data);

Private Messaging Between Two Specific Clients

Combining what we know about rooms and socket IDs, a private one-to-one message between two connected users looks like this:


    socket.on("privateMessage", (targetSocketId: string, message: string) => {
        socket.to(targetSocketId).emit("privateMessage", {
            from: socket.id,
            message,
        });
    });

Since every socket has its own default room named after its ID, targeting targetSocketId here works exactly the same way as targeting any other room, it just happens to contain exactly one client.

Broadcasting Across Multiple Devices of the Same User

If the same user has your app open in two tabs, or on both their phone and laptop, they will have two separate socket connections, each with a different socket.id. If you want an event to reach every device belonging to one user, you need a way to group those specific sockets together, typically done by having each socket join a room named after the user's actual user ID (not the socket ID) upon connecting:


    socket.on("authenticate", (userId: string) => {
        socket.join(`user:${userId}`);
    });

    io.to(`user:${userId}`).emit("notification", data);

Now, regardless of how many tabs or devices that user has open, all of them receive the notification, since they all joined the same user:<id> room.

The compress Flag

By default, Socket.IO compresses data before sending it. For very small, frequent payloads, this compression overhead can occasionally cost more than it saves. You can disable it per emit if needed:


    socket.compress(false).emit("event", data);

This is a minor optimization, and rarely something you need to think about early on, but it's useful to know it exists.

The local Flag: A Multi-Server Consideration

Once your app scales to multiple server instances (which we'll cover properly in Phase 6, using the Redis Adapter), broadcasting normally reaches clients connected to any server in the cluster. If you specifically only want to reach clients connected to the current server process, you can use .local:


    io.local.emit("event", data);

For a single-server setup, which is what we are running throughout this course, .local behaves identically to a normal broadcast, since there is only one server anyway.

A Performance Tip Worth Following

One subtle but important best practice: avoid doing async work directly inside a broadcast call, since it delays the entire operation unnecessarily:


    // Avoid this
    io.to("room2").emit("details", await fetchDetails());

    // Prefer this instead
    const details = await fetchDetails();
    io.to("room2").emit("details", details);

Resolving your data first, then emitting, keeps the broadcast itself fast and synchronous, rather than blocking on an async call in the middle of it.

Applying This to Our Chat App

Looking back at our chat app from Topic 13, we used io.emit("newMessage", data), sending to everyone including the sender, to keep the server as a single source of truth. Now you can see exactly why that specific choice was made, and how it fits into this bigger picture of broadcasting options. As your app grows, for example, adding private messages or per-user notifications, you now have the full set of tools to choose the correct emit pattern for each situation.

Summary

  • socket.emit, socket.broadcast.emit, io.emit, socket.to(room), and io.to(room) cover the majority of real-world broadcasting needs
  • .except(room) excludes specific rooms from a broadcast, and can be chained or passed an array
  • socket.to(socket.id) does not send to yourself, use plain socket.emit() for that
  • Grouping a user's multiple devices into a shared room (e.g. user:<id>) lets you broadcast to all their sessions at once
  • .compress(false) and .local are minor, situational tools, useful to know about but rarely needed early on
  • Resolve async data before emitting, rather than awaiting inside the emit call itself

This completes Phase 4. In the next post, we begin Phase 5 by handling reconnection, offline users, and connection state recovery, the first step toward making our app production-ready.

PHASE 4 — Topic 15: Working with Namespaces — Separating Different Parts of Your App

We covered Rooms in the last post. Namespaces are a related idea, but they solve a different problem. This post explains what namespaces are, how they differ from rooms, and when to actually use them.

What Is a Namespace?

A namespace is a way to split your application logic over a single underlying connection, by assigning it its own communication path. Every Socket.IO server actually already has one namespace by default, called the root namespace, written as /. Every example we have built so far, using io.on("connection", ...), has really been using this default namespace all along, without us naming it explicitly.

Namespaces let you create additional, separate paths, for example /chat, /notifications, or /admin, each with its own set of connected clients and its own event handlers. Even though they are logically separate, all namespaces still share the same underlying transport connection, so you are not paying the cost of multiple physical connections.

Namespaces vs. Rooms: The Key Difference

This is where beginners often get confused, so let's be direct about it:

  • A room is a subdivision inside a namespace, purely for grouping sockets. A client does not explicitly "connect" to a room, the server puts it there using socket.join().
  • A namespace is a separate communication channel entirely, and the client explicitly connects to it by specifying its path, similar to choosing a different URL.

A useful way to remember it: namespaces separate different parts of your application. Rooms group clients within one part of your application. A chat namespace might still use rooms internally, for example, one room per chat channel, while the /admin namespace stays completely separate and unaffected by anything happening in /chat.

Creating a Namespace on the Server

You create a namespace using io.of(path):


    const chatNamespace = io.of("/chat");

    chatNamespace.on("connection", (socket) => {
        console.log("A client connected to /chat:", socket.id);

        socket.on("chatMessage", (data) => {
            chatNamespace.emit("newMessage", data);
        });
    });

Notice that inside a namespace, you use the namespace object itself (chatNamespace) instead of io, when you want to broadcast to everyone connected to that specific namespace. chatNamespace.emit(...) only reaches clients connected to /chat, nobody connected to the default / namespace or any other namespace receives it.

Connecting to a Namespace From the Client

On the client, you specify the namespace path when creating the connection:


    import { io } from "socket.io-client";

    const chatSocket = io("/chat", {
        path: "/api/socket",
    });

Two different paths are involved here, and it's important not to confuse them:

  • /api/socket is the Engine.IO transport path, the low-level endpoint the actual connection travels through, which we configured back in Phase 2
  • /chat is the Socket.IO namespace, a logical separation on top of that same connection

Both are needed together, and they serve completely different purposes.

A Practical Example: Separating Chat and Admin

Let's say your app has a normal chat area for regular users, and a separate admin monitoring panel. Instead of mixing this logic into one giant connection handler with manual permission checks everywhere, namespaces let you cleanly separate it:


    const chatNamespace = io.of("/chat");
    const adminNamespace = io.of("/admin");

    chatNamespace.on("connection", (socket) => {
        socket.on("chatMessage", (data) => {
            chatNamespace.emit("newMessage", data);
        });
    });

    adminNamespace.on("connection", (socket) => {
        socket.on("banUser", (userId) => {
            console.log("Admin requested ban for:", userId);
        });
    });

A regular user connecting to /chat has no way of sending or receiving anything happening on /admin, and vice versa. This gives you a clean separation of concerns, without needing to check "is this an admin event?" inside every single handler.

Restricting Access to a Namespace

Since regular users should not be able to freely connect to /admin, namespaces support their own middleware, using .use(), which runs before a connection is accepted:


    adminNamespace.use((socket, next) => {
        const isAdmin = socket.handshake.auth.role === "admin";

        if (isAdmin) {
            next();
        } else {
            next(new Error("Not authorized"));
        }
    });

If next() is called with an error, the client's connection attempt to that namespace is rejected, and it never reaches the connection event at all. We will cover authentication properly, including socket.handshake.auth, in Phase 5, but this shows how namespaces naturally support access control at the connection level itself.

Dynamic Namespaces

Socket.IO also supports creating namespaces dynamically, using a regular expression, useful when you don't know your namespace names in advance, for example, one namespace per project or per organization:


    io.of(/^\/room-\d+$/).on("connection", (socket) => {
        const namespaceName = socket.nsp.name;
        console.log("Connected to dynamic namespace:", namespaceName);
    });

That said, dynamic namespaces are not common practice for most applications. In the vast majority of cases, especially something like separate chat channels, Rooms are the better and simpler tool for the job, since creating a new namespace for every single chat channel would be unnecessarily heavy. Reach for namespaces when you need to separate distinct parts of your application's logic, not when you just need to group users.

Applying This to Our Chat App

For our course project, we do not need multiple namespaces, our chat app is simple enough that the default namespace with rooms, as we built in the last post, is the right tool. This post is here so you recognize namespaces when you see them in other projects, and know exactly when reaching for one makes sense over a room.

Summary

  • A namespace is a separate communication path on the same underlying connection, created using io.of(path) on the server, and connected to using io(path, ...) on the client
  • Namespaces separate distinct parts of an application; rooms group clients within one part of an application
  • Broadcasting inside a namespace uses the namespace object itself, not io, to avoid reaching clients in other namespaces
  • Namespaces support their own middleware through .use(), useful for restricting access, such as an admin-only namespace
  • Dynamic namespaces exist for advanced cases, but rooms are usually the better choice for grouping, such as chat channels

In the next post, we look at broadcasting in more detail, covering how to send messages to everyone, or to specific users and rooms, tying together everything from events, rooms, and namespaces into clear broadcasting patterns.

PHASE 4 — Topic 14: Working with Rooms — Grouping Users Together

So far, our chat app sends every message to every connected user. That works for a single global chat, but most real apps need to separate conversations, for example, different chat channels, private conversations, or document collaboration sessions. This is exactly what Rooms are for.

What Is a Room?

A room is an arbitrary, named channel that sockets can join and leave. It is a purely server-side concept, meaning the client has no direct access to the list of rooms it belongs to, the server tracks this entirely on its own. A single socket can join multiple rooms at the same time, and one room can contain any number of sockets.

Every socket also automatically joins one room by default the moment it connects: a room named after its own socket.id. This is how io.to(socketId).emit(...), which we used back in Topic 11, actually works internally, it is really just emitting to a room containing exactly one socket.

Joining a Room

You join a room using socket.join(roomName), called on the server:


  socket.on("joinRoom", (roomName: string) => {
    socket.join(roomName);
    console.log(`${socket.id} joined room: ${roomName}`);
  });

join can also accept an array, letting a socket join several rooms in one call: socket.join(["room1", "room2"]).

Leaving a Room

Leaving works the same way, using socket.leave(roomName):


  socket.on("leaveRoom", (roomName: string) => {
    socket.leave(roomName);
    console.log(`${socket.id} left room: ${roomName}`);
  });

You do not need to manually leave rooms when a socket disconnects, Socket.IO automatically removes a socket from every room it was in as soon as it disconnects.

Sending Messages to a Room

Once sockets are grouped into a room, you can broadcast to just that group using to() (or the identical alias in()):


  io.to("room-1").emit("newMessage", data);

This sends the event to every socket currently in "room-1", and nobody else. If you want to send to a room but exclude the sender specifically, call .to() on the socket instead of io:


  socket.to("room-1").emit("newMessage", data);

This follows the exact same sender-exclusion pattern we saw earlier with socket.broadcast.emit, just scoped to one room instead of the whole server.

Updating Our Shared Types

Let's extend src/types/socket.ts to support joining named chat rooms:


    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
        newMessage: (data: ChatMessage) => void;
        onlineCount: (count: number) => void;
        roomMessage: (data: ChatMessage) => void;
    }

    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
        chatMessage: (data: { text: string; sender: string }) => void;
        joinRoom: (roomName: string) => void;
        leaveRoom: (roomName: string) => void;
        roomChatMessage: (data: {
            room: string;
            text: string;
            sender: string;
        }) => void;
    }

Updating the Server

Add this logic inside io.on("connection", (socket) => { ... }) in server.ts:


    socket.on("joinRoom", (roomName) => {
        socket.join(roomName);
    });

    socket.on("leaveRoom", (roomName) => {
        socket.leave(roomName);
    });

    socket.on("roomChatMessage", (data) => {
        io.to(data.room).emit("roomMessage", {
            id: `${socket.id}-${Date.now()}`,
            text: data.text,
            sender: data.sender,
            timestamp: Date.now(),
        });
    });

Notice we use io.to(data.room).emit(...) here, not socket.to(...), for the same reason we used io.emit(...) in the global chat: it keeps the server as the single source of truth, so the sender also receives their own message back through the same room broadcast, instead of relying on separate local state.

A Minimal Client Example

Here is how a client joins a specific room and sends a room-scoped message:


  useEffect(() => {
    socket.emit("joinRoom", "general");

    return () => {
      socket.emit("leaveRoom", "general");
    };
  }, []);

  function sendRoomMessage(text: string) {
    socket.emit("roomChatMessage", {
      room: "general",
      text,
      sender: username,
    });
  }

And listening for messages from that room:


  useEffect(() => {
    function onRoomMessage(data: ChatMessage) {
      setMessages((prev) => [...prev, data]);
    }

    socket.on("roomMessage", onRoomMessage);

    return () => {
      socket.off("roomMessage", onRoomMessage);
    };
  }, []);

A Common Pattern: Switching Rooms

In apps with multiple chat channels, users often switch from one room to another. The correct pattern is to leave the old room before joining the new one:


  function switchRoom(oldRoom: string, newRoom: string) {
    socket.emit("leaveRoom", oldRoom);
    socket.emit("joinRoom", newRoom);
  }

If you skip leaving the old room, the socket stays subscribed to both, and will keep receiving messages from a room the user thinks they've left.

Checking Which Rooms a Socket Is In

On the server, you can inspect a socket's current rooms through socket.rooms, which is a Set containing every room it has joined, including its own default socket.id room:


  socket.on("joinRoom", (roomName) => {
    socket.join(roomName);
    console.log("Current rooms:", Array.from(socket.rooms));
  });

We actually already used this in the previous post's disconnecting example, looping through socket.rooms to notify others before a socket's room memberships got cleared.

An Important Multi-Server Limitation

Rooms, as we've used them here, only exist on the single server process they were created on. If you later scale your app to run multiple server instances, a room joined on one server instance is not automatically visible to another instance. Solving this requires the Redis Adapter, which we will cover properly in Phase 6. For now, with a single server, everything here works exactly as expected.

Summary

  • Rooms are server-side channels that group sockets together, joined with socket.join() and left with socket.leave()
  • io.to(room).emit(...) sends to everyone in a room; socket.to(room).emit(...) sends to everyone in the room except the sender
  • Sockets automatically leave all rooms on disconnect, no manual cleanup needed there
  • Switching rooms means explicitly leaving the old one before joining the new one
  • socket.rooms gives you the current room list for a socket, useful for debugging and cleanup logic
  • Rooms are single-server only by default; scaling across multiple servers requires the Redis Adapter, covered in Phase 6

In the next post, we cover Namespaces, a related but different way to separate parts of your application over a single Socket.IO connection.

PHASE 4 — Topic 13: Building a Simple Real-Time Chat Application (UI with Tailwind)

Time to put everything from Phase 3 together into one complete, working feature: a real chat application. This post combines connection handling, typed events, and both directions of data flow into a single page.

Step 1: Update Our Shared Types

Open src/types/socket.ts and update it with everything our chat app needs:


    export interface ChatMessage {
        id: string;
        text: string;
        sender: string;
        timestamp: number;
    }

    export interface ServerToClientEvents {
        message: (data: { text: string; sender: string }) => void;
        hello: (text: string) => void;
        newMessage: (data: ChatMessage) => void;
        onlineCount: (count: number) => void;
    }

    export interface ClientToServerEvents {
        sendMessage: (data: { text: string; sender: string }) => void;
        sayHello: () => void;
        chatMessage: (data: { text: string; sender: string }) => void;
    }

    export interface InterServerEvents {
        ping: () => void;
    }

    export interface SocketData {
        userId: string;
    }

Step 2: Update the Server

Open server.ts and update the connection handler to track online users and broadcast chat messages:


    import { createServer } from "http";
    import { parse } from "url";
    import next from "next";
    import { Server } from "socket.io";
    import type {
        ServerToClientEvents,
        ClientToServerEvents,
        InterServerEvents,
        SocketData,
    } from "./src/types/socket";

    const dev = process.env.NODE_ENV !== "production";
    const hostname = "localhost";
    const port = parseInt(process.env.PORT || "3000", 10);

    const app = next({ dev, hostname, port });
    const handle = app.getRequestHandler();

    app.prepare().then(() => {
        const httpServer = createServer((req, res) => {
            const parsedUrl = parse(req.url!, true);
            handle(req, res, parsedUrl);
        });

        const io = new Server<
            ClientToServerEvents,
            ServerToClientEvents,
            InterServerEvents,
            SocketData
        >(httpServer, {
            path: "/api/socket",
        });

        let onlineCount = 0;

        io.on("connection", (socket) => {
            console.log("A client connected:", socket.id);

            onlineCount++;
            io.emit("onlineCount", onlineCount);

            socket.on("chatMessage", (data) => {
                io.emit("newMessage", {
                    id: `${socket.id}-${Date.now()}`,
                    text: data.text,
                    sender: data.sender,
                    timestamp: Date.now(),
                });
            });

            socket.on("sayHello", () => {
                socket.emit("hello", "Hello from the server!");
            });

            socket.on("sendMessage", (data) => {
                console.log("Received:", data.text, "from", data.sender);
            });

            socket.on("disconnect", () => {
                onlineCount--;
                io.emit("onlineCount", onlineCount);
                console.log("Client disconnected:", socket.id);
            });
        });

        httpServer.listen(port, () => {
            console.log(`Server ready on http://${hostname}:${port}`);
        });
    });

Notice we use io.emit("newMessage", ...) here, not socket.broadcast.emit(...). This is intentional for a simple chat UI: instead of managing local state separately for "my own message" versus "others' messages," we let the server be the single source of truth, and every client, including the sender, receives the message the same way, through this one event.

Step 3: Build the Chat UI

Replace src/app/page.tsx with this complete chat interface:


  "use client";

  import { useEffect, useRef, useState } from "react";
  import { socket } from "@/lib/socket";
  import type { ChatMessage } from "@/types/socket";

  export default function Home() {
    const [isConnected, setIsConnected] = useState(false);
    const [onlineCount, setOnlineCount] = useState(0);
    const [messages, setMessages] = useState<ChatMessage[]>([]);
    const [input, setInput] = useState("");
    const [username] = useState(
      () => `User-${Math.floor(Math.random() * 1000)}`
    );

    const bottomRef = useRef<HTMLDivElement>(null);

    useEffect(() => {
      setIsConnected(socket.connected);

      function onConnect() {
        setIsConnected(true);
      }

      function onDisconnect() {
        setIsConnected(false);
      }

      function onNewMessage(data: ChatMessage) {
        setMessages((prev) => [...prev, data]);
      }

      function onOnlineCount(count: number) {
        setOnlineCount(count);
      }

      socket.on("connect", onConnect);
      socket.on("disconnect", onDisconnect);
      socket.on("newMessage", onNewMessage);
      socket.on("onlineCount", onOnlineCount);

      return () => {
        socket.off("connect", onConnect);
        socket.off("disconnect", onDisconnect);
        socket.off("newMessage", onNewMessage);
        socket.off("onlineCount", onOnlineCount);
      };
    }, []);

    useEffect(() => {
      bottomRef.current?.scrollIntoView({ behavior: "smooth" });
    }, [messages]);

    function handleSend() {
      if (input.trim() === "") return;
      socket.emit("chatMessage", { text: input, sender: username });
      setInput("");
    }

    return (
      <main className="flex min-h-screen flex-col items-center bg-gray-900 text-white p-4">
        <div className="flex w-full max-w-md items-center justify-between mb-4">
          <span
            className={`text-sm ${isConnected ? "text-green-400" : "text-red-400"
              }`}
          >
            {isConnected ? "Connected" : "Disconnected"}
          </span>
          <span className="text-sm text-gray-400">
            {onlineCount} online
          </span>
        </div>

        <div className="w-full max-w-md flex-1 overflow-y-auto rounded-lg bg-gray-800 p-4 h-96">
          {messages.map((msg) => (
            <div
              key={msg.id}
              className={`mb-2 flex flex-col ${msg.sender === username ? "items-end" : "items-start"
                }`}
            >
              <span className="text-xs text-gray-400">{msg.sender}</span>
              <span
                className={`rounded-lg px-3 py-2 text-sm ${msg.sender === username
                  ? "bg-blue-600"
                  : "bg-gray-700"
                  }`}
              >
                {msg.text}
              </span>
            </div>
          ))}
          <div ref={bottomRef} />
        </div>

        <div className="mt-4 flex w-full max-w-md gap-2">
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleSend()}
            placeholder="Type a message..."
            className="flex-1 rounded bg-gray-800 px-3 py-2 text-sm outline-none"
          />
          <button
            onClick={handleSend}
            className="rounded bg-blue-600 px-4 py-2 text-sm hover:bg-blue-700"
          >
            Send
          </button>
        </div>
      </main>
    );
  }

Step 4: Understanding the Key Parts

  • username is generated once using useState(() => ...), so it stays the same for the life of the component instead of regenerating on every re-render
  • bottomRef combined with the second useEffect gives us auto-scroll: every time messages changes, we scroll a hidden div at the bottom into view smoothly
  • Messages are styled differently depending on whether msg.sender === username, giving the classic chat bubble look, your own messages on the right, others on the left
  • onKeyDown lets users press Enter to send, in addition to clicking the button
  • The onlineCount badge updates live as users connect and disconnect, using the events we set up on the server

Step 5: Test With Multiple Tabs

Run npm run dev, then open http://localhost:3000 in two or three separate browser tabs. Send a message from one tab, and you should see it appear instantly in every other tab. Close a tab, and watch the online count decrease in the remaining ones.

A Known Limitation

Right now, messages only exist in memory, inside the messages state on each client, and briefly inside your server's event flow. If you refresh the page, chat history disappears completely, since nothing is being saved anywhere. This is expected at this stage. Adding persistent storage, like a database, is outside the scope of this Socket.IO course, but worth keeping in mind if you extend this project further.

Summary

  • We combined typed events, connection tracking, and bidirectional data flow into one working chat feature
  • The server broadcasts every message to all clients using io.emit, keeping the server as the single source of truth
  • Auto-scroll was added using useRef and a useEffect that runs whenever messages changes
  • Online user count updates live, using the connect/disconnect tracking pattern from the previous post
  • Chat history is not persisted, it only exists in memory for the current session

In the next post, we go into Rooms, learning how to group specific users together so messages can be sent only to a particular group instead of everyone.

PHASE 4 — Topic 16: Broadcasting Messages to Everyone, or to Specific Users/Rooms

We've used several broadcasting patterns already across earlier posts. This post pulls everything together into one clear reference, and...