Phoenix PubSub Patterns
Use this skill before writing ANY PubSub or real-time broadcast code.
Canonical FP bar: docs/fcis-engineering-rules.md — Functional Core, Imperative Shell: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.
RULES — Follow these with no exceptions
1. Subscribe inside if connected?(socket) — never subscribe on the static render, or the disconnected and connected phases both subscribe and you get duplicate messages
2. Broadcast from context modules, not LiveViews — keep real-time logic in the business layer
3. Only broadcast on success — pattern-match {:ok, result} in a private broadcast/2 and pass {:error, changeset} through untouched
4. Update assigns immutably with update/3 in handle_info/2 — never mutate socket.assigns
5. Match subscribe and broadcast topic strings exactly — topics are case-sensitive and must be identical
6. Add a handle_info/2 clause for every broadcast event — an unhandled message crashes the LiveView; add a catch-all when other processes may send messages
7. Test the full cycle through the LiveView — call the context function and assert the rendered view updates; don't test PubSub.broadcast in isolation
Implementation Workflow
- Subscribe in
mount — guard with if connected?(socket) to prevent duplicate subscriptions
- Broadcast from context — add a private
broadcast/2 helper that fires only on {:ok, result}
- Handle in
handle_info/2 — update assigns immutably with update/3
- Verify with a test — call the context function and assert the LiveView reflects the change
Subscription Pattern
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "posts")
end
{:ok, assign(socket, :posts, list_posts())}
end
@impl true
def handle_info({:post_created, post}, socket) do
{:noreply, update(socket, :posts, fn posts -> [post | posts] end)}
end
@impl true
def handle_info({:post_updated, post}, socket) do
{:noreply,
update(socket, :posts, fn posts ->
Enum.map(posts, fn
p when p.id == post.id -> post
p -> p
end)
end)}
end
@impl true
def handle_info({:post_deleted, post}, socket) do
{:noreply,
update(socket, :posts, fn posts ->
Enum.reject(posts, &(&1.id == post.id))
end)}
end
end
Broadcasting from Contexts
Broadcast from contexts, not LiveViews — keeps real-time logic in the business layer. Topic naming conventions:
"posts" — collection-wide; events: {:post_created, post}, {:post_updated, post}, {:post_deleted, post}
"posts:#{post.id}" — specific resource; events: {:post_updated, post}, {:comment_added, comment}
"users:#{user.id}" — user-scoped; events: {:notification, notification}, {:message_received, message}
defmodule MyApp.Blog do
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
|> broadcast(:post_created)
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
|> broadcast(:post_updated)
end
def delete_post(%Post{} = post) do
post
|> Repo.delete()
|> broadcast(:post_deleted)
end
# Only broadcast on success
defp broadcast({:ok, post}, event) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {event, post})
{:ok, post}
end
defp broadcast({:error, changeset}, _event) do
{:error, changeset}
end
end
Testing the Full PubSub Cycle
Test by calling context functions and asserting the LiveView reflects the update — do not test PubSub.broadcast in isolation.
defmodule MyAppWeb.PostLive.IndexTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "creates a post and LiveView updates in real time", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/posts")
# Call the context function — it broadcasts internally
{:ok, post} = MyApp.Blog.create_post(%{title: "Hello", body: "World"})
# Assert the LiveView received and rendered the broadcast
assert render(view) =~ post.title
end
test "deletes a post and LiveView removes it", %{conn: conn} do
post = insert(:post)
{:ok, view, _html} = live(conn, ~p"/posts")
{:ok, _} = MyApp.Blog.delete_post(post)
refute render(view) =~ post.title
end
end
Troubleshooting / Validation Checkpoints
- Subscription not firing? Verify the LiveView is fully connected: subscriptions inside
if connected?(socket) only run after WebSocket upgrade, not on the initial static render.
- Broadcast sent but LiveView not updating? Confirm the topic string in
subscribe and broadcast match exactly (case-sensitive). Add a temporary IO.inspect in handle_info/2 to confirm the message is arriving.
- Duplicate messages? You subscribed outside the
if connected?(socket) guard — the static render and the live render both subscribed.
handle_info clause missing? An unhandled PubSub message will crash the LiveView process. Add a catch-all def handle_info(_, socket), do: {:noreply, socket} if other processes may send unexpected messages.
Common Pitfalls
| ❌ Don't |
✅ Do |
Subscribe outside the connected? guard |
Subscribe only inside if connected?(socket) |
| Broadcast directly from a LiveView |
Broadcast from the context after a successful write |
| Broadcast before checking the result |
Broadcast only on {:ok, result}; pass errors through |
Mismatch subscribe/broadcast topic strings |
Use identical, case-sensitive topic strings |
Mutate socket.assigns in handle_info/2 |
Update immutably with update/3 |
| Leave a broadcast event without a matching clause |
Add a handle_info/2 clause (plus a catch-all) |
Assert on PubSub.broadcast in isolation |
Drive the context function and assert the view updates |
Integration
| Predecessor |
This Skill |
Successor |
| phoenix-liveview-essentials |
phoenix-pubsub-patterns |
testing-essentials |
| liveview-streams |
phoenix-pubsub-patterns |
phoenix-channels-essentials |
Companion skills:
phoenix-liveview-essentials — LiveView lifecycle that receives the broadcasts
liveview-streams — apply broadcasts as targeted stream inserts/deletes
phoenix-channels-essentials — broadcasting to non-LiveView clients
1---2name: phoenix-pubsub-patterns3description: MANDATORY for ALL PubSub and real-time broadcast work. Invoke before writing PubSub.subscribe, broadcast, or handle_info for real-time updates. Covers subscription patterns, broadcasting from contexts, topic naming, scoped broadcasting, immutable assign updates, and testing. Trigger words: PubSub, subscribe, broadcast, handle_info, real-time, topic, presence.4license: MIT5---67# Phoenix PubSub Patterns89Use this skill before writing ANY PubSub or real-time broadcast code.101112Canonical FP bar: [`docs/fcis-engineering-rules.md`](../../docs/fcis-engineering-rules.md) — **Functional Core, Imperative Shell**: pure domain modules; side effects at edges. Keep LiveView/controller callbacks thin; delegate business rules to contexts/pure modules.1314## RULES — Follow these with no exceptions1516**1.** **Subscribe inside `if connected?(socket)`** — never subscribe on the static render, or the disconnected and connected phases both subscribe and you get duplicate messages17**2.** **Broadcast from context modules, not LiveViews** — keep real-time logic in the business layer18**3.** **Only broadcast on success** — pattern-match `{:ok, result}` in a private `broadcast/2` and pass `{:error, changeset}` through untouched19**4.** **Update assigns immutably with `update/3`** in `handle_info/2` — never mutate `socket.assigns`20**5.** **Match `subscribe` and `broadcast` topic strings exactly** — topics are case-sensitive and must be identical21**6.** **Add a `handle_info/2` clause for every broadcast event** — an unhandled message crashes the LiveView; add a catch-all when other processes may send messages22**7.** **Test the full cycle through the LiveView** — call the context function and assert the rendered view updates; don't test `PubSub.broadcast` in isolation2324---2526## Implementation Workflow27281. **Subscribe in `mount`** — guard with `if connected?(socket)` to prevent duplicate subscriptions292. **Broadcast from context** — add a private `broadcast/2` helper that fires only on `{:ok, result}`303. **Handle in `handle_info/2`** — update assigns immutably with `update/3`314. **Verify with a test** — call the context function and assert the LiveView reflects the change323334## Subscription Pattern3536```elixir37defmodule MyAppWeb.PostLive.Index do38 use MyAppWeb, :live_view3940 @impl true41 def mount(_params, _session, socket) do42 if connected?(socket) do43 Phoenix.PubSub.subscribe(MyApp.PubSub, "posts")44 end4546 {:ok, assign(socket, :posts, list_posts())}47 end4849 @impl true50 def handle_info({:post_created, post}, socket) do51 {:noreply, update(socket, :posts, fn posts -> [post | posts] end)}52 end5354 @impl true55 def handle_info({:post_updated, post}, socket) do56 {:noreply,57 update(socket, :posts, fn posts ->58 Enum.map(posts, fn59 p when p.id == post.id -> post60 p -> p61 end)62 end)}63 end6465 @impl true66 def handle_info({:post_deleted, post}, socket) do67 {:noreply,68 update(socket, :posts, fn posts ->69 Enum.reject(posts, &(&1.id == post.id))70 end)}71 end72end73```747576## Broadcasting from Contexts7778Broadcast from contexts, not LiveViews — keeps real-time logic in the business layer. Topic naming conventions:79- `"posts"` — collection-wide; events: `{:post_created, post}`, `{:post_updated, post}`, `{:post_deleted, post}`80- `"posts:#{post.id}"` — specific resource; events: `{:post_updated, post}`, `{:comment_added, comment}`81- `"users:#{user.id}"` — user-scoped; events: `{:notification, notification}`, `{:message_received, message}`8283```elixir84defmodule MyApp.Blog do85 def create_post(attrs) do86 %Post{}87 |> Post.changeset(attrs)88 |> Repo.insert()89 |> broadcast(:post_created)90 end9192 def update_post(%Post{} = post, attrs) do93 post94 |> Post.changeset(attrs)95 |> Repo.update()96 |> broadcast(:post_updated)97 end9899 def delete_post(%Post{} = post) do100 post101 |> Repo.delete()102 |> broadcast(:post_deleted)103 end104105 # Only broadcast on success106 defp broadcast({:ok, post}, event) do107 Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {event, post})108 {:ok, post}109 end110111 defp broadcast({:error, changeset}, _event) do112 {:error, changeset}113 end114end115```116117118## Testing the Full PubSub Cycle119120Test by calling context functions and asserting the LiveView reflects the update — do not test `PubSub.broadcast` in isolation.121122```elixir123defmodule MyAppWeb.PostLive.IndexTest do124 use MyAppWeb.ConnCase, async: true125 import Phoenix.LiveViewTest126127 test "creates a post and LiveView updates in real time", %{conn: conn} do128 {:ok, view, _html} = live(conn, ~p"/posts")129130 # Call the context function — it broadcasts internally131 {:ok, post} = MyApp.Blog.create_post(%{title: "Hello", body: "World"})132133 # Assert the LiveView received and rendered the broadcast134 assert render(view) =~ post.title135 end136137 test "deletes a post and LiveView removes it", %{conn: conn} do138 post = insert(:post)139 {:ok, view, _html} = live(conn, ~p"/posts")140141 {:ok, _} = MyApp.Blog.delete_post(post)142143 refute render(view) =~ post.title144 end145end146```147148149## Troubleshooting / Validation Checkpoints150151- **Subscription not firing?** Verify the LiveView is fully connected: subscriptions inside `if connected?(socket)` only run after WebSocket upgrade, not on the initial static render.152- **Broadcast sent but LiveView not updating?** Confirm the topic string in `subscribe` and `broadcast` match exactly (case-sensitive). Add a temporary `IO.inspect` in `handle_info/2` to confirm the message is arriving.153- **Duplicate messages?** You subscribed outside the `if connected?(socket)` guard — the static render and the live render both subscribed.154- **`handle_info` clause missing?** An unhandled PubSub message will crash the LiveView process. Add a catch-all `def handle_info(_, socket), do: {:noreply, socket}` if other processes may send unexpected messages.155156157## Common Pitfalls158159| ❌ Don't | ✅ Do |160|----------|-------|161| Subscribe outside the `connected?` guard | Subscribe only inside `if connected?(socket)` |162| Broadcast directly from a LiveView | Broadcast from the context after a successful write |163| Broadcast before checking the result | Broadcast only on `{:ok, result}`; pass errors through |164| Mismatch `subscribe`/`broadcast` topic strings | Use identical, case-sensitive topic strings |165| Mutate `socket.assigns` in `handle_info/2` | Update immutably with `update/3` |166| Leave a broadcast event without a matching clause | Add a `handle_info/2` clause (plus a catch-all) |167| Assert on `PubSub.broadcast` in isolation | Drive the context function and assert the view updates |168169---170171## Integration172173| Predecessor | This Skill | Successor |174|-------------|------------|-----------|175| phoenix-liveview-essentials | phoenix-pubsub-patterns | testing-essentials |176| liveview-streams | phoenix-pubsub-patterns | phoenix-channels-essentials |177178**Companion skills:**179- `phoenix-liveview-essentials` — LiveView lifecycle that receives the broadcasts180- `liveview-streams` — apply broadcasts as targeted stream inserts/deletes181- `phoenix-channels-essentials` — broadcasting to non-LiveView clients