← Back to blog
ARCHITECTURE9 min read

The Pub/Sub Pattern Explained: Topics, Rooms, and Message Routing

HB
Henco Burger
April 7, 2026

Publish/subscribe is one of those patterns that sounds abstract until you have built a real-time feature and realised that direct connections do not scale. Once you understand it properly, you see it everywhere: chat apps, IoT platforms, financial data feeds, multiplayer games, and collaborative editing tools all rely on some form of pub/sub under the hood.

This post explains pub/sub from the ground up, covers the three key concepts (topics, rooms, and filters), and walks through how they work together in practice.

The Problem with Direct Connections

Imagine a simple chat app. User A sends a message. Users B, C, and D need to see it. The naive approach: User A maintains a direct WebSocket connection to B, C, and D. User A sends the message directly to each of them.

This works in a demo. It falls apart at scale. If you have 1,000 users in a room, every sender needs 999 active connections. The number of connections grows as O(n²). At 10,000 concurrent users you need 100 million connections. Even ignoring the impossibility of that at the network level, the sender now has to manage connection state for every other user. What happens when User B disconnects? What if User C is on a slow connection and cannot keep up? What if User A wants to send to multiple rooms simultaneously?

Direct connections couple the sender to the receiver. Pub/sub breaks that coupling.

The Core Idea: Decouple Publishers from Subscribers

In a pub/sub system, neither side knows about the other. A publisher sends a message to a named channel. A subscriber declares interest in a named channel. The broker, the thing in the middle, takes messages from publishers and delivers them to interested subscribers.

// Publisher: doesn't know who is listening
broker.publish('chat:general', { text: 'Hello!', from: 'alice' })

// Subscriber: doesn't know who is publishing
broker.subscribe('chat:general', (message) => {
  renderMessage(message)
})

This decoupling has several important properties. Publishers can exist without any subscribers. Subscribers can join and leave without affecting publishers. The broker can fan out a single published message to thousands of subscribers without the publisher doing any extra work. And the broker can buffer messages for subscribers who are temporarily offline.

Topics: Named Channels for Message Routing

A topic is the fundamental routing unit in a pub/sub system. It is just a named channel. Publishers address messages to a topic. Subscribers declare interest in a topic. The broker routes messages from publishers to all current subscribers of that topic.

Topics are typically hierarchical strings. Common conventions:

// MQTT-style hierarchy with /
sensors/floor-2/temp-sensor-4
users/alice/notifications
orders/eu-west/status

// Colon-delimited (common in web systems)
chat:general:messages
presence:room-42
game:session-7f3a:events

The topic string is what the broker uses to route messages. It has no inherent meaning to the broker, it is just a string it uses as a key. The meaning is in the convention your app establishes.

Wildcards and Patterns

Most pub/sub systems support wildcard subscriptions. Instead of subscribing to one specific topic, you subscribe to a pattern that matches many topics.

// Subscribe to all sensor readings on floor 2
broker.subscribe('sensors/floor-2/+', handler)   // MQTT single-level wildcard

// Subscribe to all events for any session
broker.subscribe('game/+/events', handler)

// Subscribe to everything under orders
broker.subscribe('orders/#', handler)            // MQTT multi-level wildcard

Wildcard subscriptions are powerful but they push more routing work onto the broker. Most production systems have limits on wildcard subscription depth or require them to be granted explicitly.

Rooms: Grouping Topics for Application Logic

Topics are a low-level concept. They are just named channels. Rooms are a higher-level concept that groups related topics together around a shared context. In a chat app, a room is a channel that users join together. It contains a topic for messages, a topic for presence events, a topic for typing indicators, and possibly a topic for reactions.

// All topics that belong to the "general" room
room:general:messages
room:general:presence
room:general:typing
room:general:reactions

When a user joins the "general" room, they subscribe to all four of these topics. When they leave, they unsubscribe from all four. The room is an abstraction that bundles a set of subscriptions and gives them a shared lifecycle.

Without the room abstraction, the application would have to manage these topic subscriptions individually. With it, you call joinRoom('general') and the SDK handles the rest.

Filters: Narrowing Message Delivery

Even within a topic, not every subscriber always wants every message. A dashboard might be showing orders for a specific customer. A mobile app might only want notifications above a certain severity level. Filters let subscribers express finer-grained interest without creating a separate topic for every possible combination of criteria.

// Subscribe to orders topic but only receive messages for customer_id 42
broker.subscribe('orders', handler, {
  filter: { customerId: '42' }
})

// Subscribe to notifications but only severity >= 'warning'
broker.subscribe('notifications', handler, {
  filter: { severity: ['warning', 'error', 'critical'] }
})

Filters can be applied at the broker (the broker only delivers matching messages, which saves bandwidth) or at the client (the client receives all messages but discards non-matching ones). Broker-side filtering is more efficient but requires the broker to understand your message schema. Client-side filtering is simpler to implement but wastes bandwidth on messages the client will ignore.

How Pub/Sub Scales vs Direct Connections

Going back to the 1,000-user chat room: with pub/sub, every user has exactly one connection, to the broker. When User A publishes to room:general:messages, the broker fans the message out to all 999 other subscribers. User A does not know how many subscribers there are. It does not maintain a connection to any of them. The connection count grows as O(n), not O(n²).

The broker can also be distributed. Multiple broker nodes can share subscription state, so a message published to one node gets delivered by all nodes. This is how systems like Kafka, Redis Pub/Sub, and NATS scale horizontally. The application logic does not change when you add broker nodes. The routing layer absorbs the scale.

Using Pub/Sub in Practice: The Chat App

Here is how all three concepts (topics, rooms, filters) work together in a real chat app with multiple channels:

// User joins two rooms
const general = await chat.joinRoom('general')
const engineering = await chat.joinRoom('engineering')

// Under the hood, each joinRoom subscribes to multiple topics:
// room:general:messages, room:general:presence, room:general:typing
// room:engineering:messages, room:engineering:presence, room:engineering:typing

// A message published to room:general:messages only reaches
// subscribers of that specific topic. Users in #engineering
// do not receive messages from #general.

// User sends a message - published to room:general:messages
await general.sendMessage({ text: 'Morning everyone' })

// User receives only messages from rooms they have joined
general.onMessage(msg => appendToGeneralFeed(msg))
engineering.onMessage(msg => appendToEngineeringFeed(msg))

This is the pattern at the core of every real-time communication app. The room abstraction makes it ergonomic. The topic routing underneath makes it scalable. The broker in the middle makes it decoupled.

NoLag's Three-Noun Model: App, Room, Topic

NoLag organises all of this around three nouns.

An app is the top-level namespace. It maps to your application and has its own API keys, connection limits, and subscriber counts. All rooms and topics belong to an app. Isolation between apps is complete: no topic in one app is visible to another.

A room is a grouping context within an app. It corresponds to the room abstraction described above: a conversation channel, a game session, a document, a project. Rooms have their own presence lists, message history, and permission policies. Users connect to a room, not to individual topics.

A topic is the actual message channel within a room. Each room contains multiple topics for different event types. When you call joinRoom() on the SDK, it subscribes to all standard topics for that room. You can also create custom topics inside a room for application-specific event streams.

This three-level hierarchy covers the vast majority of real-time use cases without requiring you to design a topic namespace from scratch. App, room, topic. That is the model.