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.
The core idea
Section titled “The core idea”Pub/sub splits the world into topics (also called channels or rooms) and three roles:
- Subscribers tell the server which topics they care about —
room: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"]
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 are just topics
Section titled “Rooms are just topics”“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.
A message shape for it
Section titled “A message shape for it”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.
See it live
Section titled “See it live”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.
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.
Why this scales
Section titled “Why this scales”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.