Engineering Atlas › System Design Fundamentals

Publish-subscribe messaging

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Topics, subscriptions and subscribers

Three names keep the model straight. A topic is the named stream publishers write to. A subscription is a named, independent view of that topic with its own position and its own unacknowledged messages. A subscriber is a process attached to a subscription. When the billing team and the analytics team both need order-events, each gets its own subscription; billing can run five consumers that share the work, and analytics can be down for an hour without touching billing.

This is the fan-out problem shape. Kubernetes shows it in a second domain: controllers watch resources through the API server, each watch carries its own resourceVersion cursor, and a watcher that falls too far behind gets 410 Gone and must relist. With etcd 3, the history kept is the last 5 minutes by default (Kubernetes API concepts). That is a subscription whose cursor fell off the end of retention.

Ephemeral versus durable

Ephemeral pub/sub is a router. Redis PUBLISH returns the number of clients that received the message, and a client that reconnects one second later has missed it forever; Redis documents its pub/sub as at-most-once (Redis pub/sub). It is ideal for signals where only the latest state matters: cache invalidation hints, presence, live cursor positions, "wake up and check the database" nudges.

Ephemeral fan-out still has a scaling trap. In a Redis Cluster, a classic PUBLISH is broadcast to every node over the cluster bus, so adding shards adds traffic instead of capacity. Redis 7.0 added sharded pub/sub (SPUBLISH, SSUBSCRIBE), which hashes each channel to one slot and keeps its messages inside that shard.

Durable pub/sub stores messages until each subscription acknowledges them or retention expires. A new subscription normally starts at "now": Google Pub/Sub does not deliver messages published before the subscription existed (Google Pub/Sub subscriptions), and a Kafka consumer group with auto.offset.reset=latest starts at the end of the log. The usual mistake is deploying the producer before the subscription exists and losing the first hour of events.

Choosing what to receive

Topic hierarchies with wildcards encode routing into the topic name. MQTT uses / as the separator, + for exactly one level and # for any number of trailing levels, so factory/+/temp matches factory/line3/temp, and factory/# matches everything under factory, including factory itself. NATS uses . with and >; AMQP topic exchanges use . with and # (see the RabbitMQ lesson). MQTT also hides topics that start with $, such as $SYS/broker/load, from filters whose first level is a wildcard, so a # subscriber does not drown in broker internals (MQTT 5.0).

Attribute filters match on message metadata instead, such as SNS filter policies ({"region": ["eu-west-1"]}, see SNS message filtering) or Google Pub/Sub filter expressions. They keep topic names simple but are evaluated per message per subscription.

Retained messages solve the late-joiner problem for state. An MQTT publish with the retain flag replaces the broker's stored "last value" for that topic, and a new subscriber immediately receives the retained message for every topic its filter matches. A thermostat display that subscribes to home/+/temp gets every room's current temperature at once instead of waiting for the next reading.

Push or pull

Durable systems deliver either by pull (subscribers fetch, which gives natural flow control) or by push (the broker calls an HTTPS endpoint, Lambda or queue, and treats a 2xx as an ack). Push is convenient across organisational boundaries and behaves much like webhooks (see the Webhooks lesson); pull scales better for heavy consumers because they control their own rate.

Down the stack

Layer 1The contract

Durable pub/sub promises that every message published after a subscription is created is delivered at least once to that subscription, independently of every other subscription, until it is acked or ages past retention. In Google Pub/Sub that is seven days by default, configurable from 10 minutes to 31 days, and an unacked message is redelivered after its ack deadline, 10 seconds by default and at most 600 (Pub/Sub API reference). Order is not promised unless you use an ordering key or partition key, and then only per key and per subscription. One subscriber's failure must not slow or lose messages for another. Ephemeral pub/sub promises only at-most-once delivery to subscribers connected at publish time. Neither promises that subscribers see a message at the same moment.

Layer 2The mechanism

There are two ways to implement fan-out. Copy on publish writes a copy, or a reference, into each subscription's own queue: SNS delivering to SQS queues, a RabbitMQ fanout exchange enqueueing into each bound queue. Each subscription then has normal per-message ack state, filtering and dead-lettering. Shared log with cursors writes the message once and gives each subscription a position. Kafka stores each group's committed offset in the internal __consumer_offsets topic. Pulsar keeps a cursor per subscription with a mark-delete position plus ranges of individually acknowledged messages beyond it, so out-of-order acks do not force redelivery (Pulsar messaging concepts).

Wildcard matching uses a trie keyed by topic level. Brokers such as Mosquitto and EMQX store subscriptions in a tree where each node has children for literal levels plus + and # edges. Matching factory/line3/temp walks one path per level and branches at most into the wildcard edges, so cost grows with topic depth, not with the number of subscriptions.

MQTT delivery state is per client session. QoS 1 uses PUBLISH and PUBACK and may duplicate; QoS 2 adds PUBREC, PUBREL and PUBCOMP so each side can discard duplicates by packet identifier. A session started with Clean Start = 0 and a session expiry interval keeps queueing QoS 1 and 2 messages while the client is offline.

Layer 3The machine

Fan-out multiplies egress bytes: a 1 KB message to 10,000 subscribers is 10 MB on the wire, and the NIC and TLS encryption pay for every copy. Copy on publish also multiplies storage writes; a shared log writes once and lets readers share the page cache, and Kafka sends file data to sockets with sendfile without copying it through user space. The price is that a subscriber reading hours behind pulls cold segments from disk and evicts hot ones that up-to-date readers need.

Slow ephemeral subscribers are handled brutally, by design. Redis buffers outgoing pub/sub messages per client and, with the default client-output-buffer-limit pubsub 32mb 8mb 60 (redis.conf), disconnects a client whose buffer exceeds 32 MB, or stays over 8 MB for 60 seconds. The messages in that buffer are lost. The broker protects its memory, and the subscriber has to resynchronise from a source of truth.

Why it's built this way

  1. Producers should not change when a new consumer appears, so they address a topic, not recipients.
  2. Consumers run at different speeds and fail independently, so each subscription needs its own position and ack state.
  3. Copying every message N times is expensive in disk and memory, so high fan-out systems store once and keep N cursors, while systems needing per-message filters and DLQs per subscriber accept the copies.
  4. Storage is finite, so retention is bounded, and the system must choose between dropping a slow subscriber's backlog and letting it pin storage for everyone. Mature systems drop the subscriber, not the stream.

Diagram

flowchart LR
  P1["Order service"] --> T["Topic order-events (log)"]
  T --> S1["Sub billing (cursor 1042)"]
  T --> S2["Sub analytics (cursor 870)"]
  T --> S3["Sub emails (filter: type=placed)"]
  S1 --> C1["Billing worker 1"]
  S1 --> C2["Billing worker 2"]
  S2 --> C3["Analytics job"]
  S3 --> C4["Email sender"]

Worked example

A sports app sends live scores. Matches publish 50 updates per second in total, each about 400 bytes, on topics like scores/football/m123. Two million phones are connected to 40 gateway servers, 50,000 each, and a cup final has 500,000 watchers.

If each phone were a durable subscription, the broker would track 2 million cursors and back up messages for phones that went to sleep. Instead the design uses two tiers. Each gateway holds one subscription to scores/# on the internal broker, so a goal in the final is published once and fanned out 40 times. Each gateway then fans out to its own sockets: on average 12,500 local watchers of the final, 5 MB per goal per gateway, about 40 ms at 1 Gbit/s. The heavy fan-out happens in the cheap, ephemeral last mile.

Phones that reconnect do not replay missed updates; the score is state, not history. The broker retains the last message per match topic, so a reconnecting gateway, or a phone that opens the app mid-match, gets the current score immediately. The events that truly need durability, such as push notifications for goals, statistics and the archive, are three durable subscriptions on the internal log. If the archive job stops for 2 hours, it falls 50 × 400 B × 7,200 s, about 144 MB, behind: trivial to retain and replay.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Ephemeral (Redis pub/sub, NATS core, MQTT QoS 0)Microsecond routing, no storage, simpleOffline or slow subscribers lose messagesSignals, presence, live state with a source of truth elsewhere
Copy per subscription (SNS to SQS, RabbitMQ fanout)Per-subscriber acks, filters, DLQs, retryStorage and requests multiply with subscriber countFew subscribers, each with its own retry and failure policy
Shared log with cursors (Kafka, Pulsar)Write once, replay, cheap to add subscribersOrdering and parallelism per partition, coarse acksMany subscribers, high volume, replay needed
Managed pub/sub (Google Pub/Sub)No capacity planning, push or pull, filtersPer-message cost, limited replay windowCloud-native services without a streaming platform team

Pitfalls in production

Leadership lens

Check yourself

  1. Billing and analytics both subscribe to order-events. Billing runs three consumers. How many times is each message processed?

    Show answer

    Once by billing, by whichever of its three consumers gets it, and once by analytics. Fan-out is across subscriptions; competition is within one.

  2. Why does an MQTT broker match subscriptions with a trie rather than testing each filter?

    Show answer

    Testing every filter costs time proportional to the number of subscriptions per message. A trie walk costs time proportional to topic depth, plus the wildcard branches at each level.

  3. When is a retained message a better fix for a late subscriber than durable replay?

    Show answer

    When the topic carries state where only the latest value matters, such as a temperature or a score. The newcomer needs the current value, not every past change.

  4. Why can one lagging subscriber hurt others on a shared log?

    Show answer

    It reads cold data from disk, evicting page-cache pages that up-to-date readers need, and if retention is tied to the slowest cursor it forces the log to keep growing.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →