Publish-subscribe: the pattern that decouples everything

LLM-authored, human-reviewed

Concurrency & the BEAM

Every “it just updated” moment on a website uses the same pattern. A chat message appears without a refresh. A spreadsheet cell changes on another screen. Stripe fires a webhook at your server. Hot reload recompiles your editor’s open tabs. These are all publish-subscribe, one of the few architectural patterns that applies almost everywhere. It sits behind the broker, the browser, the webhook, and the build tool. The topology stays the same. The trust boundary changes. This article covers the pattern itself: its roles, semantics, and the one question that separates every implementation. The next article shows what happens when the pattern is not infrastructure but the runtime’s native operation, and what this platform teaches about it.

One topology, four embodiments

Publish-subscribe has three roles. A publisher emits an event without knowing who listens. A subscriber expresses interest and receives events without knowing who sent them. An intermediary sits between them, routing and fanning out events. The textbook definition adds the consequence that sets this pattern apart: the three parties are decoupled in space (they do not know each other’s addresses), in time (they need not exist at the same moment), and in synchronization (a publisher does not block on its subscribers). The research literature dates the full statement to Eugster et al.’s 2003 survey in ACM Computing Surveys. The pattern is older, growing out of the GoF Observer pattern (1994), TIBCO’s Information Bus (presented at SOSP in 1993), and the enterprise-integration canon’s Publish-Subscribe Channel (Hohpe and Woolf, 2003). The commercial and academic traditions agree on the mechanism: the intermediary removes references between the parties, so either side can change without the other noticing.

The same three roles appear in four places. Trust decides who trusts whom:

Embodiment Publisher Intermediary Subscriber Trust boundary
Message broker A service Kafka / RabbitMQ / NATS Another service Inside a VPC
Browser push The server WebSocket / SSE connection A browser tab Server to client
Webhook A vendor (Stripe, GitHub) Delivery workers Your server Organization to organization
Hot reload OS file watcher Dev server (Vite, esbuild) The running app Kernel to browser, localhost

The file watcher makes the relationship clear: this is one pattern, not four coincidences. inotify and FSEvents publish filesystem events, chokidar subscribes to them, and the dev server routes the changes to whatever module boundary accepted them. The dev loop is publish-subscribe in miniature, with the same failure modes. Security and transport change with the trust boundary: brokers require authenticated clients inside a trusted network, webhooks need HMAC signatures because the receiver is a stranger, and hot reload trusts localhost with no authentication at all. The pattern stays the same. The paranoia changes.

The message semantics

Once the topology is fixed, the important choices are semantic. The industry has converged on a small set of them.

Subscription shape. The simplest subscription uses a topic: a string label on the event (“order.created”) that the intermediary matches against subscriber interests. Modern brokers add server-side attribute filtering (AWS SNS filter policies, GCP filters, Azure rules), so subscribers can narrow by event content without the broker shipping everything. The classic alternatives—content-based routing and type-based routing—are mostly history. In practice, use topic plus filter.

Delivery shape. Three structures answer three questions. A queue delivers each message to exactly one consumer (work distribution). A topic delivers each message to every subscriber, with an isolated copy per subscription (fan-out). An event stream is a durable, ordered log that subscribers replay from an offset (audit, reprocessing, late joiners). The choice comes down to who owns history: queues forget, topics duplicate, streams retain.

Delivery guarantee. This is where the marketing meets the math. A system can deliver at-most-once (fire and forget), at-least-once (retry until acknowledged, risking duplicates), or exactly-once. The third is the trap. Exactly-once delivery is impossible in general—the Two Generals’ Problem is exactly this—so every “exactly-once” claim in production is limited to a specific protocol trick (Kafka’s idempotent producers and transactions deliver exactly-once within a partition per producer session, not globally). The field’s settled formula is the one every layer reached independently: at-least-once delivery plus idempotent consumers. Make your handler safe to run twice—a dedup table keyed by event ID, a natural key on the write—and retries stop being scary. Idempotency is the universal solvent for the impossibility theorem.

Ordering. Global ordering effectively does not exist in production publish-subscribe. Every system that offers order sells it per key and charges throughput: Kafka orders within a partition, GCP’s ordering keys cap at roughly 1 MB/s per key, SNS FIFO topics throttle and cost several times more, MQTT guarantees order per topic per client only. Partition by entity key from day one. Make the consumer tolerate disorder across keys. Order belongs to a partition, not to the system.

Push vs. pull. The delivery direction depends on who owns the consumer, not on performance. Pull models (Kafka consumer groups, GCP pull subscriptions) dominate inside an organization, where consumers are trusted and want flow control. Push dominates at boundaries: webhooks push to strangers because no producer can hold connections open to thousands of untrusted consumers, and browsers cannot be pulled because they sit behind NATs. The engineering slogan is simple: brokers at the core, webhooks at the edge.

The slow subscriber

The one question that separates every implementation is: what happens to your slowest subscriber? When consumption lags production, systems give four answers. Redis Pub/Sub and most WebSocket stacks drop—Redis closes or silences slow subscribers past its output-buffer limits, and browsers lose messages that a dead tab never receives. RabbitMQ and the managed brokers buffer and dead-letter—they queue the excess, then shunt poisoned or expired messages to a DLQ for inspection. Kafka makes the lag visible: consumer offsets expose exactly how far behind a group is, turning it into a monitoring dashboard rather than a policy. Demand-driven pipelines invert control: the subscriber requests events, so a slow consumer simply stops asking and no events are produced for it. The four philosophies are drop, buffer, measure, and invert. Choosing a technology mostly means choosing among them, because the slow subscriber is the failure mode that actually happens.

The cost of decoupling

The pattern’s virtue is also its tax. Martin Fowler’s critique is the canonical warning: decoupled flows are invisible—the volume and shape of traffic is not evident from reading any one codebase, so large-scale flows grow unnoticed; they are scheme-coupled—publishers and subscribers must agree on event schemas, which is why the industry standardizes envelopes (CloudEvents) and signatures (Standard Webhooks) rather than transports; and they produce event storms—one publication triggers a cascade of publications, each a legitimate response to the last, together forming a cascade no one designed. Debugging a publish-subscribe failure means working without stack traces because the caller and callee never met. The standardizing bodies’ answer is straightforward: fix the envelope, fix the signature, keep the transport plural. Every real-time system you have used is a row in the table above. The skills transfer across rows. Learn the pattern once, and it pays for all four embodiments. The next article follows it to the one runtime that made it a primitive rather than a product.

Fan-out is send

On the BEAM the intermediary is optional. One publisher, three subscribers:

Enum.each(subscribers, fn pid -> send(pid, {:event, payload}) end)

Each pid is a process. send copies the message into that process’s mailbox. The publisher does not wait, does not share memory, and does not know whether the subscriber is a LiveView, a GenServer, or a one-off spawn. That is the topic row of the table: one publish, every subscriber gets a copy.

The subscriber side is receive:

receive do
  {:event, payload} -> handle(payload)
end

Ping-Pong Counter is this pair with two processes. Topic Pub/Sub with Wildcards adds topics, wildcards, and cleanup when a subscriber dies. The next article is what happens when the runtime is the broker.

Where to go next

← Back to articles