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:
-
send/2does 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. -
The broadcaster does the work. If the broadcaster is your
Channel process, your Channel's
handle_in/3is blocked until the loop completes. For a topic with 100k subscribers, that's 100ksend/2calls—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:
- Each node maintains its own ETS table of local subscribers.
-
Nodes maintain a process group (
:pg) for the PubSub instance: every node's PubSub server is a member. -
When a node broadcasts, it:
-
Delivers locally first (the ETS-and-
sendloop above). -
Sends the broadcast message to every other PubSub server in the
group via
send/2over Erlang distribution. - Each remote PubSub server receives the message and runs the local delivery loop on its own subscribers.
-
Delivers locally first (the ETS-and-
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
Taskthat 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/3is slowing itself down. Useintercept/1only when you must (it amplifies cost), prefer narrow topics, and offload broadcasts toTaskif 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
-
Phoenix.PubSubHexDocs: the official API documentation. -
:pgErlang documentation: the process group module that backs cross-node delivery. - Channel callbacks, annotated: the API surface that lives on top of this model.
- Handle 100,000 concurrent connections on a single node: the operational consequences in tuning form.