How Phoenix.PubSub distributes messages

This is an explanation. It builds a mental model of how Phoenix.PubSub (the broadcast layer underneath Phoenix Channels, LiveView, and any application that uses it directly) actually delivers messages. By the end you'll understand why some broadcasts are essentially free, why others touch every node in your cluster, and which design decisions in your application interact with which parts of the PubSub model.

This is not a how-to. There are no commands to run. It's the kind of document you read once, refer back to twice a year, and recognize when its mental model surfaces in a profiling session.

The shape of the problem

A real-time application wants to do something like this: "send this message to every connection that subscribed to topic room:42." Easy in a single-process program. Subtle in a system with:

  • Many concurrent subscribers (potentially 100,000+ per node).
  • Many topics, most with few subscribers but a few with many.
  • Multiple nodes in a cluster, with subscribers spread across them.
  • A requirement that broadcasts complete fast enough not to back-pressure the broadcaster.
  • A requirement that one slow subscriber doesn't slow down delivery to all the others.

Phoenix.PubSub is the layer Phoenix uses to solve this problem. It's deliberately small, a few hundred lines of Elixir, and built on a small number of primitives that the BEAM already provides: ETS for in-process lookup, process groups for cross-node coordination, and send/2 for actual delivery.

Subscribers are processes

The first decision that shapes everything: a subscriber is a process, identified by its PID. There's no callback registration, no subscriber object, no message queue per topic. To subscribe to a topic, a process calls:

Phoenix.PubSub.subscribe(MyApp.PubSub, "room:42")

This registers self()'s PID in a per-topic ETS table on the local node.

To broadcast:

Phoenix.PubSub.broadcast(MyApp.PubSub, "room:42", {:new_message, content})

The broadcaster looks up every subscribed PID and calls send(pid, message) for each.

That's the entire model on a single node. ETS lookup → send/2 loop. Both operations are extremely fast in the BEAM—a million send/2 calls per second is well within reach.

What the ETS table actually holds

Each PubSub instance maintains an ETS table whose key is the topic string and whose values are subscriber records (a tuple of {pid, value}, where value is an optional opaque term passed at subscription time).

The table is configured with {:read_concurrency, true} and {:write_concurrency, true}, so concurrent broadcasts and subscriptions don't contend on a single lock.

At 100,000 subscribers per node:

  • Memory: ETS entries are ~50–100 bytes each. 100k entries is around 5–10 MB. Negligible.
  • Lookup: a hash on the topic, then an iteration over the bucket. O(n) in the number of subscribers to that topic. If you have one topic with 100,000 subscribers, a broadcast loops 100,000 times.
  • Concurrent writes (subscribes/unsubscribes): handled by ETS write concurrency. Multiple processes can join different topics in parallel without serializing.

The shape of your topic structure matters. Many small topics is fine. One enormous topic with hundreds of thousands of subscribers will dominate broadcast cost.

Local delivery: the fast path

When a Channel calls broadcast!/3, this is what happens on the local node:

def broadcast(adapter, topic, message) do
subscribers = :ets.lookup(table, topic)
Enum.each(subscribers, fn {pid, _} ->
send(pid, message)
end)
end

(Simplified—the real implementation does dispatcher fan-out and adapter-specific work, but this is the shape.)

Two things matter operationally:

  1. send/2 does not block. It enqueues the message in the receiver's mailbox and returns. The broadcaster is free to send to the next subscriber. This is how broadcasting to 100,000 subscribers in milliseconds is possible.
  2. The broadcaster does the work. If the broadcaster is your Channel process, your Channel's handle_in/3 is blocked until the loop completes. For a topic with 100k subscribers, that's 100k send/2 calls—fast but not instantaneous. Your channel's other messages queue behind the broadcast.

The second point is where the model starts to leak at scale. We'll come back to it.

Cross-node delivery: the slower path

In a multi-node cluster, subscribers are spread across nodes. The default adapter (Phoenix.PubSub.PG2, now backed by Erlang's :pg module) handles cross-node delivery this way:

  1. Each node maintains its own ETS table of local subscribers.
  2. Nodes maintain a process group (:pg) for the PubSub instance: every node's PubSub server is a member.
  3. When a node broadcasts, it:
    • Delivers locally first (the ETS-and-send loop above).
    • Sends the broadcast message to every other PubSub server in the group via send/2 over Erlang distribution.
    • Each remote PubSub server receives the message and runs the local delivery loop on its own subscribers.

This means: a broadcast on a 5-node cluster crosses the cluster once (one message per remote node), not once per remote subscriber. If 99,000 of your 100,000 subscribers are on remote nodes, you still only pay the cost of 4 cluster crossings, then four parallel local delivery loops.

The cross-node hop adds latency. On a healthy local network with Erlang distribution tuned reasonably, expect 1–5 ms additional latency per broadcast that crosses nodes. On a WAN, much more.

Where the model leaks

The PubSub model is elegant and fast, but it has known failure modes that surface at scale.

Broadcaster head-of-line blocking

Because the broadcaster runs the local delivery loop synchronously, a topic with many subscribers makes the broadcaster slow. In a Channel, the broadcaster is the channel process—so a broadcast!/3 on a 100k-subscriber topic means your channel can't process new messages until the loop finishes.

This isn't usually a problem for typical chat-style workloads (hundreds of subscribers per topic), but it does show up when one topic accumulates enormous fan-out, e.g., a "system announcement" topic everyone subscribes to. Mitigations:

  • Use narrower topics so any one broadcast touches a smaller subscriber set.
  • Move the broadcast off the channel process: spawn a Task that does the broadcast, and let the channel return immediately.

Subscriber backpressure invisibility

A subscriber that's slow to drain its mailbox (say, a Channel process whose client has a flaky connection) doesn't slow the broadcaster down. send/2 returns immediately. But it does mean the subscriber's mailbox grows, consuming memory.

In extreme cases, a flooded subscriber can OOM the node. This is rare in normal Channel workloads (because the WebSocket client either keeps up or disconnects), but it's worth knowing about—especially if you have non-Channel subscribers to busy topics.

Cross-node retransmission

:pg-based PubSub sends each broadcast to every other node in the cluster, whether that node has subscribers or not. If you have a 20-node cluster and most topics only have subscribers on 2 or 3 nodes, you're paying for 17 unnecessary cluster crossings per broadcast.

This is a known limitation. For applications where the topic locality is high (most broadcasts touch a few nodes), the Redis adapter, which subscribes to topics rather than nodes, can be more efficient. The tradeoff is an extra moving part in your infrastructure.

Subscriber identity is the PID

A subscriber is its PID. When a process crashes and restarts, it's a new PID—the old subscription is dead but still in the ETS table until the PubSub server notices via the process's monitor. There's no way to attach stable identity to a subscriber across crashes.

This is fine in practice for Channels (a crashed channel means a disconnected client; resubscription happens on reconnect), but it's a wrinkle if you're using PubSub for non-Channel subscribers and you care about durable subscriptions. For those use cases, an external broker (Redis, Kafka, NATS) is a better fit.

Why this matters for Channels

Every Phoenix Channel is a PubSub subscriber under the hood. When you call broadcast!/3 from a channel, you're doing exactly what we've described: ETS lookup, local delivery loop, cross-node fan-out via :pg.

The operational consequences:

  • The number of channels per topic is what costs you on broadcast, not the number of channels overall. 100,000 channels spread across 10,000 topics is much cheaper to broadcast over than 100,000 channels on a single topic.
  • Broadcast cost is paid by the broadcaster. A channel that broadcasts on a high-fan-out topic from handle_in/3 is slowing itself down. Use intercept/1 only when you must (it amplifies cost), prefer narrow topics, and offload broadcasts to Task if the topic is large.
  • Cross-node latency is real, but bounded. A single broadcast crosses each remote node once, regardless of subscriber count on that node. This is what makes Channels scale horizontally—the broadcast cost grows linearly with subscriber count per node, not total subscriber count.

Further reading