Skip to content

Rooms and Broadcast

The broadcast — one event reaching many clients at once — is the reason WebSocket servers exist. This lesson covers the two flavours of broadcast (to everyone and to a room) and the data structures that make rooms work.

The ws library gives you wss.clients, a Set of every currently open socket. To send something to all of them, iterate and send — checking that each socket is actually open first:

import { WebSocket } from 'ws';
function broadcastAll(wss, message) {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}

The readyState === WebSocket.OPEN guard matters: a socket can be mid-close or already closing, and calling send on it throws or silently buffers. Always filter to OPEN before sending. This is the simplest possible broadcast — useful for a global announcement, but a sledgehammer for anything targeted.

Most apps do not want everyone to hear everything. A chat message belongs to one channel; a game update belongs to one match. The pattern is a room — a named group of sockets. The classic data structure is a Map from room name to a Set of sockets:

const rooms = new Map(); // roomName -> Set<ws>
function join(room, ws) {
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
}
function leave(room, ws) {
rooms.get(room)?.delete(ws);
if (rooms.get(room)?.size === 0) rooms.delete(room); // tidy up empty rooms
}
function broadcastRoom(room, message) {
for (const client of rooms.get(room) ?? []) {
if (client.readyState === WebSocket.OPEN) client.send(message);
}
}

Now a message fans out only to the sockets that joined that room — not the whole server. Notice the symmetry with the previous lesson: each socket’s state.subscriptions set is the client’s view of its rooms, while this rooms map is the server’s reverse index. Keep the two in sync.

flowchart TB
  SENDER["client A<br/>(in room 'sports')"] -- "publish to 'sports'" --> S["server"]
  S --> ROOM["room 'sports'"]
  ROOM == "deliver" ==> B["client B ✓ in sports"]
  ROOM == "deliver" ==> C["client C ✓ in sports"]
  S -. "not delivered" .-> D["client D ✗ in 'news'"]
Fan-out: a message to one room reaches only that room's members

Client D, which joined a different room, never hears the message — that selectivity is the entire point of rooms.

A common refinement: when broadcasting a chat line, you often skip the sender, because their own client already showed the message optimistically. Just compare against the sending socket:

for (const client of rooms.get(room) ?? []) {
if (client !== sender && client.readyState === WebSocket.OPEN) {
client.send(message);
}
}

Whether to include the sender is an application choice — some apps echo back so the sender’s UI confirms the server received it. Either way, the client !== sender toggle is how you control it.

The demo below implements rooms on a real ws server. A client sends {"type":"join","room":"x"} to join, then {"type":"say","text":"hi"} to broadcast to everyone in its room. Cleanup on close removes the socket from its room so you never broadcast to a ghost. Open it in StackBlitz and connect a couple of clients to the same room.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

With two clients joined to the same room, a say from one arrives at the other but not back at the sender (we passed ws as the except argument). A third client in a different room hears nothing. That targeted fan-out — backed by a Map of Sets and disciplined cleanup — is the core engine of every chat, presence, and live-collaboration feature you will build.

What is `wss.clients` in the ws library?
Why check `client.readyState === WebSocket.OPEN` before calling `send` during a broadcast?
What data structure cleanly models rooms for targeted broadcast?