Phoenix PubSub Fan-Out Patterns
Engineering

Phoenix PubSub Fan-Out Patterns: Topics Are a Contract

PubSub is free β€” until the fan-out is wrong and one broadcast wakes 10,000 LiveView sockets. Three topic naming rules that keep Prismatic's live dashboards responsive at scale.

Apr 09, 2026 Β· 6 min read Β· TomΓ‘Ε‘ Korcak (korczis)

Phoenix.PubSub.broadcast(Prismatic.PubSub, "updates", msg) is a one-liner. It is also how a careless change wakes every connected LiveView on the platform. PubSub is trivially cheap per-subscriber, which means it is catastrophically expensive at the wrong fan-out. Three rules keep /hub responsive.

#Rule 1: Topics are scoped, not global

A global topic like "updates" is a sign that someone stopped thinking. Real topics carry the smallest scope that correctly identifies the audience:

# ❌ wakes everyone
Phoenix.PubSub.broadcast(Prismatic.PubSub, "updates", {:dd_case_updated, case})

# βœ… wakes only viewers of this one case
Phoenix.PubSub.broadcast(Prismatic.PubSub, "dd:case:#{case.id}", {:updated, case})

The topic is the contract. A LiveView that subscribes to "dd:case:#{id}" is promising β€œI care about this case and nothing else.” Broadcasting to that topic from an unrelated code path is a bug β€” it means someone violated the contract.

#Rule 2: Topic names are a taxonomy

Prismatic uses a three-segment pattern: <domain>:<object>:<id>. Flat namespaces don’t scale mentally past ~20 topics. A taxonomy lets you grep production and answer β€œwho subscribes to this?” in seconds:

dd:case:<id>         # per-case updates
dd:entity:<id>       # per-entity changes
osint:run:<run_id>   # per-OSINT-run execution stream
system:alert         # global alerts (use sparingly)
system:error_feed    # error stream (use sparingly)

The last two are the exception to rule 1. Global topics are allowed when the audience is every operator β€” but every such topic needs a written justification.

#Rule 3: Broadcast the smallest payload

A PubSub message is copied into every subscriber’s mailbox. A 100 KB payload Γ— 500 subscribers = 50 MB of BEAM heap allocated per broadcast. Don’t do that. Send the minimum β€” an id, an event type, a version β€” and let each subscriber decide whether to fetch more:

# ❌ 100 KB Γ— N copies
broadcast(topic, {:case_updated, case_with_all_entities})

# βœ… N copies of 40 bytes
broadcast(topic, {:case_updated, case.id, case.version})

Subscribers that care can fetch/1 the full record. Subscribers that don’t (idle LiveViews, closed tabs that haven’t disconnected yet) throw the message away cheaply.

#Fan-out via Registry for low-cardinality cases

For low-cardinality, high-frequency broadcasts (say, 50 subscribers), Registry.dispatch/3 is cheaper than PubSub and gives you synchronous back-pressure. Use it when the set of subscribers is small and known. Use PubSub when the set is large or unknown.

#Observability on every broadcast

Every broadcast path emits telemetry:

:telemetry.execute([:pubsub, :broadcast], %{bytes: payload_size}, %{topic: topic})

The dashboard groups by topic. A topic that suddenly starts broadcasting 10Γ— more bytes is a regression worth a ticket. Observability for PubSub is the only way to catch these before users notice.

#Where to go next

Topics are a contract. Payloads are copies. Broadcast accordingly.

Browse all β†’