Channel callbacks, annotated
This is a reference. Each callback in the Phoenix.Channel behavior is
documented here with its signature, valid return values, and a short
annotation describing the operational behavior—the kind of detail the
official docs leave to the reader to discover.
The callbacks are listed in the order they typically run in a channel's lifetime.
join/3
@callback join(topic :: String.t(), payload :: map(), socket :: Phoenix.Socket.t()) ::
{:ok, Phoenix.Socket.t()}
| {:ok, reply :: map(), Phoenix.Socket.t()}
| {:error, reply :: map()}
Runs: Once per connection, when the client sends a phx_join frame
for a topic this channel handles.
Purpose: Authorize the join, set up per-connection state, and optionally send an initial payload back to the client.
Return values:
-
{:ok, socket}: accept the join with no reply payload. -
{:ok, reply, socket}: accept the join and sendreplyto the client. The client receives it in the.receive("ok", fn)callback. -
{:error, reply}: reject the join. The client receives it in.receive("error", fn).
Annotations:
-
A returned
socketfromjoin/3becomes the starting state of the channel process. Subsequent callbacks receive a socket derived from this one, not the connect-time socket. -
join/3runs in the channel process, not the socket process. Slow joins (e.g., a database lookup taking 200 ms) block other channels from being joined on the same connection—clients see this as join latency. -
Pattern-match on the topic to extract its dynamic segment:
def join("room:" <> room_id, ...). This is the idiomatic way to route a single channel module across many topics. -
If you need to send the first message immediately after join,
send(self(), :after_join)and handle it inhandle_info/2. Don't broadcast fromjoin/3—the socket isn't fully wired up yet, and the new client won't receive the broadcast.
handle_in/3
@callback handle_in(event :: String.t(), payload :: map(), socket :: Phoenix.Socket.t()) ::
{:noreply, Phoenix.Socket.t()}
| {:reply, {:ok, reply :: map()} | {:error, reply :: map()}, Phoenix.Socket.t()}
| {:stop, reason :: term(), Phoenix.Socket.t()}
| {:stop, reason :: term(), reply :: term(), Phoenix.Socket.t()}
Runs: Every time the client pushes a message
(channel.push("event", payload) on the JS side).
Return values:
-
{:noreply, socket}: ack the message; no reply sent. -
{:reply, {:ok, reply}, socket}: reply with a payload the client receives via.push(...).receive("ok", fn). -
{:reply, {:error, reply}, socket}: reply with an error payload. -
{:stop, reason, socket}: terminate the channel after handling.
Annotations:
-
The channel process handles one message at a time. A
handle_in/3that does 50 ms of work caps that channel's throughput at 20 msgs/sec. For slow work, spawn aTaskand return{:noreply, socket}immediately. -
payloadis whatever JSON the client sent, decoded to a map with string keys. There's no schema enforcement—you're responsible for validating. -
To broadcast to other subscribers, use
broadcast!/3(sends to all, including sender) orbroadcast_from!/3(sends to all except sender). To reply only to the sender, use the{:reply, ...}return form.
handle_out/3
@callback handle_out(event :: String.t(), payload :: map(), socket :: Phoenix.Socket.t()) ::
{:noreply, Phoenix.Socket.t()}
| {:stop, reason :: term(), Phoenix.Socket.t()}
Runs: When the channel is about to send an outgoing message to the
client—but only if you've intercepted the event with intercept/1.
Annotations:
-
This callback is rarely needed and easy to misuse. The default
behavior (no
handle_out/3) is to forward every broadcast directly to the client without invoking the channel process at all—which is what makes Channels fast. -
Intercepting an event means every broadcast on that event passes
through every subscribed channel process before going to its client.
At 100k subscribers, that's 100k message-passes per broadcast. Use
intercept/1only when you genuinely need to customize the payload per subscriber (e.g., filtering based on the receiving user's permissions). -
If you find yourself reaching for
intercept/1, consider whether the filtering belongs at the broadcast site instead: using narrower topics, or sending different events to different subsets of subscribers.
handle_info/2
@callback handle_info(message :: term(), socket :: Phoenix.Socket.t()) ::
{:noreply, Phoenix.Socket.t()}
| {:stop, reason :: term(), Phoenix.Socket.t()}
Runs: When any Erlang message arrives at the channel's mailbox:
sent via send/2, Process.send_after/3, a Phoenix.PubSub.broadcast
to a topic the channel is subscribed to outside the Channel mechanism,
etc.
Annotations:
- This is the channel's general-purpose escape hatch. Anything that needs to run on the channel process but isn't initiated by the client goes here.
-
The classic pattern is
send(self(), :after_join)fromjoin/3, thenhandle_info(:after_join, socket)to push initial state to the client. This avoids the trap of broadcasting before the socket is wired up. -
handle_info/2is also where messages fromProcess.monitor/1arrive—useful if your channel watches another process and needs to react to its death.
terminate/2
@callback terminate(reason :: term(), socket :: Phoenix.Socket.t()) :: term()
Runs: When the channel process is shutting down: client
disconnected, channel returned :stop, the supervisor restarted, or
the BEAM is shutting down.
Annotations:
-
The return value is ignored.
terminate/2is for side effects: releasing locks, decrementing counters, persisting last-known state. -
terminate/2is not guaranteed to run. If the BEAM dies (OOM kill, hard crash), the callback doesn't fire. Don't rely on it for correctness—only for cleanup of resources the OS or the BEAM won't reclaim on its own. -
reasontells you why. Common values:{:shutdown, :closed}(client disconnected),:normal(the channel returned:stop), or any exception term if the channel crashed.
Helpers (not callbacks)
These are functions you call from within callbacks, brought into scope
by use Phoenix.Channel:
| Function | Purpose |
|---|---|
broadcast!(socket, event, payload) |
Send to every subscriber on the topic, including the sender. |
broadcast_from!(socket, event, payload) |
Send to every subscriber except the sender's channel process. |
push(socket, event, payload) |
Send only to the sender. Cheaper than broadcast! for direct replies. |
intercept([event_names]) |
Mark events that should trigger handle_out/3. Module-level macro. |
assign(socket, key, value) |
Add/update a per-connection assign. Same shape as LiveView assigns. |
See also
-
How Phoenix.PubSub distributes messages:
the broadcast layer that backs
broadcast!/3. - Handle 100,000 concurrent connections on a single node: the operational consequences of channel-process count.