5 min read
On this page

PubSub and Presence

The reason WhatsApp ran on a few dozen Erlang servers when they had hundreds of millions of users isn't magic. It's that the BEAM treats "deliver this message to a process" and "track which users are online" as native operations, not problems you bolt on with Redis. Phoenix's PubSub and Presence modules expose those primitives in a clean API, and LiveView is what makes them visible in your UI.

If you've ever built a chat app with Socket.IO, a Redis pub/sub backend, and a presence service stitched together with cron jobs, what follows will feel almost insultingly simple.

Phoenix.PubSub: Broadcast, Subscribe, Done

Phoenix.PubSub is a topic-based message bus. Any process can subscribe to a topic and broadcast messages on it. The subscribers receive the message in their mailbox, where handle_info/2 picks it up.

In a freshly generated Phoenix app, you already have a PubSub instance running, usually named MyApp.PubSub. The two functions you'll use most:

Phoenix.PubSub.subscribe(MyApp.PubSub, "room:#{room_id}")
Phoenix.PubSub.broadcast(MyApp.PubSub, "room:#{room_id}", {:new_message, message})

In a LiveView, subscribe in mount/3 (after checking connected?(socket), so you don't double-subscribe during the initial HTTP render) and handle the broadcast with handle_info/2.

defmodule MyAppWeb.RoomLive do
  use MyAppWeb, :live_view

  def mount(%{"id" => room_id}, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, "room:#{room_id}")
    end

    {:ok,
     socket
     |> assign(:room_id, room_id)
     |> stream(:messages, Chat.list_messages(room_id))}
  end

  def handle_event("send", %{"text" => text}, socket) do
    {:ok, message} = Chat.create_message(socket.assigns.room_id, text)

    Phoenix.PubSub.broadcast(
      MyApp.PubSub,
      "room:#{socket.assigns.room_id}",
      {:new_message, message}
    )

    {:noreply, socket}
  end

  def handle_info({:new_message, message}, socket) do
    {:noreply, stream_insert(socket, :messages, message)}
  end
end

That's a working chat room. Every connected user with this LiveView open sees new messages appear instantly. There's no special server-side glue — the LiveView process is just another process, and the pubsub bus delivers to all of them.

A subtle but important detail: when you broadcast, you also receive your own message (since your own LiveView subscribed to that topic). That's usually what you want — the user sees their message appear via the same code path as everyone else, so there's no inconsistency.

If you want to skip your own broadcasts, use broadcast_from/4:

Phoenix.PubSub.broadcast_from(
  MyApp.PubSub,
  self(),
  "room:#{room_id}",
  {:new_message, message}
)

Multi-Node PubSub

Out of the box, Phoenix.PubSub uses the :pg adapter (process groups, built into OTP). When you connect multiple BEAM nodes via distributed Erlang, broadcasts automatically reach subscribers on every node. No Redis. No NATS. No external broker.

Connect nodes by setting cookies and running Node.connect/1, or use a library like libcluster to do it automatically:

# config/runtime.exs
config :libcluster,
  topologies: [
    myapp: [
      strategy: Cluster.Strategy.Kubernetes,
      config: [
        service: "myapp-headless",
        application_name: "myapp"
      ]
    ]
  ]

Once nodes are connected, Phoenix.PubSub.broadcast/3 reaches every subscriber across the cluster. This is the part that lets a chat app scale across machines without rewriting the messaging layer.

The :pg adapter has tradeoffs at very high scale (every message goes to every node), but for most apps it's fine well into the millions of messages per minute. If you need a different topology, swap the adapter — Phoenix.PubSub.Redis exists but is rarely necessary.

Phoenix.Presence: Who's Online

Presence is "given a topic, give me the set of currently connected users." Sounds simple. It isn't, because nodes can disconnect, processes can crash, and you need eventual consistency across a cluster.

Phoenix.Presence solves this with a CRDT (conflict-free replicated data type). Each node tracks its own presences and gossips them to the cluster. If a node dies, the surviving nodes notice and remove its presences. You write almost no code for this.

Set up a Presence module:

defmodule MyAppWeb.Presence do
  use Phoenix.Presence,
    otp_app: :my_app,
    pubsub_server: MyApp.PubSub
end

Add it to your supervision tree:

children = [
  MyApp.Repo,
  {Phoenix.PubSub, name: MyApp.PubSub},
  MyAppWeb.Presence,
  MyAppWeb.Endpoint
]

Then in a LiveView, track presence on mount:

defmodule MyAppWeb.RoomLive do
  use MyAppWeb, :live_view
  alias MyAppWeb.Presence

  @topic "room:lobby"

  def mount(_params, %{"user_id" => user_id}, socket) do
    if connected?(socket) do
      {:ok, _} = Presence.track(self(), @topic, user_id, %{
        online_at: System.system_time(:second),
        username: get_username(user_id)
      })

      Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
    end

    {:ok, assign(socket, users: list_users())}
  end

  def handle_info(%{event: "presence_diff", payload: _diff}, socket) do
    {:noreply, assign(socket, users: list_users())}
  end

  defp list_users do
    Presence.list(@topic)
    |> Enum.map(fn {user_id, %{metas: [meta | _]}} ->
      %{id: user_id, username: meta.username, online_at: meta.online_at}
    end)
  end
end

Presence.track/4 registers the current process as "present" under a key (here, the user ID) with arbitrary metadata. When the process dies — including when the LiveView socket closes — Presence automatically untracks. You never write cleanup code.

Presence broadcasts a presence_diff event to subscribers whenever the membership changes. You can either re-fetch the full list (simple, fine for small rooms) or apply the diff incrementally (better for large rooms).

def handle_info(%{event: "presence_diff", payload: %{joins: joins, leaves: leaves}}, socket) do
  users =
    socket.assigns.users
    |> Map.merge(presences_to_map(joins))
    |> Map.drop(Map.keys(leaves))

  {:noreply, assign(socket, users: users)}
end

The same user can be tracked multiple times (multiple tabs, multiple devices) — Presence collapses them into a single entry with a metas list, one per process.

A Real-Time Dashboard

Combine PubSub broadcasts from your business logic with a LiveView that subscribes, and you have a live dashboard with no extra infrastructure.

defmodule MyApp.Orders do
  def create_order(attrs) do
    with {:ok, order} <- %Order{} |> Order.changeset(attrs) |> Repo.insert() do
      Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_created, order})
      {:ok, order}
    end
  end
end

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
      :timer.send_interval(5_000, self(), :refresh_stats)
    end

    {:ok,
     socket
     |> assign(:stats, Orders.stats())
     |> stream(:recent_orders, Orders.recent(20))}
  end

  def handle_info({:order_created, order}, socket) do
    {:noreply,
     socket
     |> stream_insert(:recent_orders, order, at: 0, limit: 20)
     |> assign(:stats, Orders.stats())}
  end

  def handle_info(:refresh_stats, socket) do
    {:noreply, assign(socket, :stats, Orders.stats())}
  end
end

Every operator with the dashboard open sees orders stream in as they're created, anywhere in the app — over HTTP, from a background job, from another LiveView. The producer doesn't know who's listening; the listener doesn't know who's producing.

This pattern is how Bleacher Report runs live game updates, how customer-support tools update tickets in real time, and how internal admin dashboards stay current without polling.

Common Pitfalls

Subscribing in mount/3 without checking connected?(socket). The first mount runs over HTTP and has no socket to receive messages — you'll subscribe, never unsubscribe, and accumulate dead subscriptions on the PubSub registry until they're garbage collected.

Broadcasting from inside a database transaction. If the transaction rolls back, subscribers already saw the broadcast. Always broadcast after the transaction commits.

# Wrong
Repo.transaction(fn ->
  order = Repo.insert!(changeset)
  Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:new, order})
end)

# Right
with {:ok, order} <- Repo.insert(changeset) do
  Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:new, order})
  {:ok, order}
end

Treating Presence as a database. It's eventually consistent, kept in memory, and rebuilds from scratch when a node restarts. Use it for "who's online right now," not for "who has ever logged in."

Forgetting that broadcasts hit every subscriber, including the broadcaster's own LiveView. If you've already updated state locally before broadcasting, you'll double-update when your own message comes back.

Building presence on top of database polling because "I already have a users table." Polling doesn't tell you who has the page open right now — it tells you who logged in some time in the past. Use Presence.

Key Takeaways

Phoenix.PubSub is a topic-based message bus that works in-process, across nodes, with no external broker. Subscribe in mount/3 after checking connected?(socket), broadcast from your business logic, handle messages in handle_info/2. Phoenix.Presence tracks who's connected with automatic cleanup on disconnect, using a CRDT for cluster-wide consistency. Together, they make real-time chat, live dashboards, and presence indicators routine work — not architecture decisions.