jonnifyelixir
F6 / F6.07 / presence

F6.07 · part 3 of 3

Channels & presence

Two real-time tools sit around PubSub, and it helps to know what each is for. A channel is the lower-level primitive Phoenix has always had — a bidirectional topic a client joins, with explicit join/3 and handle_in/3 callbacks and the ability to push messages out. LiveView is built on top of channels; you reach for a channel directly when you need a custom protocol over the socket — a native mobile client, a game, a hardware feed — where you control the wire rather than rendering HTML. Most web UI uses LiveView, so channels are the exception, not the default. Presence is the other tool: it answers “who is on this topic right now?” You call track/3 when a process joins, Presence keeps the set of who is present, and — the part that makes it special — it syncs that set across every node in a cluster using a CRDT, so the count is correct whether your users are connected to one server or ten. A change in presence broadcasts a "presence_diff" you handle like any other message, which is how a live “N people viewing” indicator stays accurate as people come and go.

Four parts: the three real-time tools, how Presence stays correct across a cluster, a minimal channel in code, and tracking viewers to show a live count.

Channel, Presence, track

Three tools, three jobs. A channel is a bidirectional topic any client can join and speak on. Presence reports who is subscribed to a topic, synced across nodes. track/3 registers the current process as present. Select one.

Real-time tools · select one

A CHANNEL join/3 + handle_in/3 — a topic any client can speak on the primitive Presence Presence.list(topic) — who is here, synced across nodes who is on track/3 Presence.track(self(), topic, key) — register this process register

tool: a channel

is: a bidirectional topic for any client

If you are rendering HTML, you almost certainly want LiveView, not a raw channel. Channels earn their place when the other end is not a browser and you need to define the message protocol yourself.

Presence across a cluster

Presence is interesting because the answer to “who is here?” is distributed. Each node tracks the processes connected to it; nodes exchange their views and merge them with a CRDT — a data structure that converges to the same result regardless of message order. So Presence.list(topic) on any node returns the full set, not only that node’s share. A viewer count is correct across the whole cluster, not per-server.

two nodes → CRDT merge → one shared set

NODE 1 tracks: A, B local viewers NODE 2 tracks: C local viewers CRDT sync (order-independent merge) list("courses") = A, B, C same answer on either node presence is cluster-wide — a viewer count does not depend on which server you hit

This is the BEAM’s distribution story showing through again: the same code that works on one node works on a cluster, because presence merges rather than coordinates. You write track and list; the convergence is handled.

A minimal channel

A channel is a module with join/3 — deciding who may join a topic — and handle_in/3 for messages the client sends. This one accepts a join on "catalog:lobby" and replies to a "ping". It is the shape LiveView uses internally; you would write one directly only for a non-LiveView client.

defmodule PortalWeb.CatalogChannel do
  use PortalWeb, :channel

  @impl true
  def join("catalog:lobby", _payload, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_in("ping", _payload, socket) do
    {:reply, {:ok, %{pong: true}}, socket}
  end
end

Notice handle_in/3 is the channel’s version of handle_event, and a channel can push to its client the way a LiveView diffs to a browser. LiveView is a refinement of this primitive, not a separate system.

A live viewer count

In a LiveView, track/3 on the connected mount registers this viewer on the topic. Presence broadcasts a "presence_diff" whenever anyone joins or leaves, which arrives in handle_info/2 like a PubSub event; counting Presence.list/1 gives the current number. The displayed count rises and falls live, correctly, across every server.

def mount(_params, _session, socket) do
  if connected?(socket) do
    Portal.subscribe("courses")
    PortalWeb.Presence.track(self(), "courses", socket.id, %{})
  end

  {:ok, assign(socket, courses: Portal.list_courses(), viewers: 0)}
end

# Presence broadcasts a diff whenever anyone joins or leaves
def handle_info(%{event: "presence_diff"}, socket) do
  count = map_size(PortalWeb.Presence.list("courses"))
  {:noreply, assign(socket, viewers: count)}
end

track on join

track/3 registers this viewer; Presence syncs the set.

count on diff

A presence_diff updates the live count, correct cluster-wide.

That completes F6.07. The domain broadcasts events, LiveViews subscribe and react, and Presence tracks who is watching — real-time on the BEAM’s own message passing. The next module, F6.08 — deployment, takes the whole application to production. Back to the module overview or the F6 chapter.