Skip to content

Pub/Sub and Rooms

A WebSocket gives you one pipe to each client. The naive thing to do with that pipe is to push everything down it and let the client sort out what it cares about. That works for exactly one demo and then collapses: a busy app has thousands of events per second and any single user wants a tiny fraction of them. Publish/subscribe is the pattern that fixes this, and it is the backbone of nearly every real-time product you have ever used.

Pub/sub splits the world into topics (also called channels or rooms) and three roles:

  • Subscribers tell the server which topics they care aboutroom:general, prices:BTC, doc:42.
  • Publishers send a message to a topic, without knowing or caring who is listening.
  • The broker (your server) keeps the map of “who is subscribed to what” and, when a message arrives for a topic, delivers it only to that topic’s subscribers.

The decoupling is the whole point. A publisher never addresses a person; it addresses a topic. A subscriber never names a sender; it names an interest. Add or remove either side and nothing else changes.

flowchart LR
  pub["publisher<br/>sends to 'room:general'"] --> broker["broker<br/>(topic → subscribers map)"]
  broker -- "room:general" --> a["client A<br/>subs: room:general"]
  broker -- "room:general" --> b["client B<br/>subs: room:general"]
  broker -. "not subscribed" .-x c["client C<br/>subs: prices:BTC"]
One publish to a topic fans out only to that topic's subscribers

Client C never hears the room:general message — not because it was filtered on the client, but because the broker never sent it. That distinction matters: client-side filtering still pays the bandwidth and the wakeup cost for every event. Real pub/sub filters at the source.

“Rooms” and “channels” are not a different mechanism — they are a friendly name for topics scoped to a group. room:general is a topic; so is room:swe-team. Joining a room is subscribing; leaving is unsubscribing. The same machinery handles a chat room, a per-document collaboration session, and a per-symbol price feed. Once you have a clean subscribe/publish core, “rooms” cost you nothing extra.

Pub/sub needs only a tiny vocabulary on the wire. A type field plus a topic is enough to carry every operation:

type PubSubMessage =
| { type: 'subscribe'; topic: string }
| { type: 'unsubscribe'; topic: string }
| { type: 'publish'; topic: string; data: unknown }
// server -> client delivery
| { type: 'message'; topic: string; data: unknown };

The first three are commands the client sends; the last is what the server sends back when an event matches one of your subscriptions. That is the entire protocol — everything else is bookkeeping inside the broker.

The demo below is a complete in-page pub/sub broker wired to two simulated clients over the real WebSocket API. Client A subscribes to room:general; client B subscribes to prices:BTC. Then we publish to each topic and watch only the right client receive each message. Run it and read the log: every delivery lands on exactly one subscriber, never both.

JavaScript

Read the output top to bottom: A hears only room:general, B hears only prices:BTC, and the publish to room:empty is delivered to zero subscribers and silently dropped. That last line is important — publishing to a topic nobody wants is a no-op, which is exactly what lets publishers fire freely without knowing the audience.

The broker holds a Map from topic to a set of sockets. Delivering an event touches only the sockets in one set, never the whole connection table. That is why pub/sub scales with interest rather than with total connections: a hundred-thousand-user app where each user watches three topics still delivers each event to just the handful who asked. When you later run multiple server instances, the same model extends outward — a shared broker (Redis, NATS, a message bus) fans the publish across instances, which is exactly the scaling pattern you saw earlier.

In pub/sub, who does a publisher address when sending a message?
Why is broker-side filtering better than letting every client receive everything and filter locally?
What happens in the demo when a message is published to a topic with no subscribers?