12 Phoenix LiveView Patterns for Production
Twelve Phoenix LiveView patterns that hold up in production — streams vs assigns, async mounts, PubSub gating, and the failure mode behind each one.
TL;DR: Every pattern below is a scar. I learned each one because the absence of it took down a page, leaked a row a user shouldn’t have seen, or pinned a node’s memory until it OOM’d. This isn’t a tips list — tips are cheap and forgettable. It’s twelve named failure modes and the LiveView construct that prevents each. If you’re shipping LiveView past the tutorial stage, you’ve already hit some of these or you’re about to. The meta-pattern at the end is the one that actually matters: the boundary between the dead render and the connected socket is where most LiveView bugs live.
LiveView tutorials age well — the framework is stable, the docs are
excellent, and a fresh mix phx.gen.live gets you a working
CRUD page in minutes. What the tutorials don’t give you is the
second-order knowledge: the things that work fine with ten rows and fall
over with ten thousand, the patterns that are obviously correct in
hindsight and obviously absent in every junior PR — and in most of what AI coding agents generate.
So this is the listicle I wish existed when I was three production LiveView apps in. Each item is a pattern, the specific failure it prevents, a short real snippet, and — because honesty matters more than completeness — a note on when the pattern is overkill. Verified against Phoenix LiveView 1.1.
I’ve grouped them loosely: rendering and data, async, real-time, navigation and UI, and safety. Read top to bottom or jump to the one that’s currently on fire.
Rendering and data
1. Use streams for unbounded lists — don’t hold 10k rows in assigns
The failure mode: You
assign(:messages, all_messages) and it works great in dev.
In production the list grows to 8,000 rows, and now every LiveView
process holds all 8,000 in memory, re-diffs the whole collection on
every update, and ships fat payloads down the socket. A few hundred
concurrent users and the node’s memory chart looks like a hockey
stick.
Streams keep the collection in the DOM, not in the socket’s assigns. The server holds only the IDs it needs to address.
def mount(_params, _session, socket) do
{:ok, stream(socket, :messages, Chat.recent_messages())}
end
def handle_info({:new_message, msg}, socket) do
# prepend, and prune the DOM to 50 items so it never grows unbounded
{:noreply, stream_insert(socket, :messages, msg, at: 0, limit: 50)}
end<div id="messages" phx-update="stream">
<div :for={{dom_id, msg} <- @streams.messages} id={dom_id}>
{msg.body}
</div>
</div>The :limit option prunes the rendered DOM;
at: 0 prepends. To replace the whole list (filter changes,
sort changes), use
stream(socket, :messages, new_list, reset: true).
When it doesn’t apply: Small, bounded lists — a nav
menu, a status dropdown, the seven items in a wizard. Streams add a
layer of indirection (you lose direct access to the collection in the
socket; you can’t Enum.count it server-side). For genuinely
small write-once data, plain assigns are simpler and that simplicity is
worth it.
2. Don’t query
the database in mount unconditionally
The failure mode: mount/3 runs
twice — once for the dead render (the initial HTTP
request, no socket) and once when the client establishes the WebSocket.
If you run an expensive query at the top of mount with no
guard, every page load hits the database twice, and the dead-render
query blocks the first byte of HTML.
Gate the work on connected?/1, or hand it to
assign_async (pattern 3). The connected?/1
branch gives you a cheap dead render and does the real load only on the
live connection:
def mount(_params, _session, socket) do
socket =
if connected?(socket) do
assign(socket, :dashboard, Analytics.expensive_rollup())
else
assign(socket, :dashboard, nil)
end
{:ok, socket}
endWhen it doesn’t apply: SEO-critical routes that must
render real content in the dead pass for crawlers. There you do
want the data on the first render — but back it with a cache so the dead
render is cheap, and don’t lean on connected? to skip it.
Know which kind of page you’re on before you reach for this.
3.
Use assign_async for slow mounts so first paint isn’t
blocked
The failure mode: A dashboard mount fans out to three slow services. With everything inline, the user stares at a blank page until the slowest one returns. Worse, if one call raises, the whole mount crashes and the user gets a reconnect loop instead of a partial page.
assign_async/3 runs the work in a linked task (only when
connected), paints the page immediately, and patches the result in when
it arrives. The assign becomes a
Phoenix.LiveView.AsyncResult struct, and the
<.async_result> component handles the loading and
failed states declaratively.
def mount(_params, _session, socket) do
{:ok,
assign_async(socket, :stats, fn ->
{:ok, %{stats: Analytics.expensive_rollup()}}
end)}
end<.async_result :let={stats} assign={@stats}>
<:loading><.spinner /></:loading>
<:failed :let={_reason}>Couldn't load stats. Retrying…</:failed>
<.stat_grid data={stats} />
</.async_result>Use start_async/3 (paired with the
handle_async/3 callback) when you need full control over
what happens with the result — side effects, multiple assigns, custom
error handling — rather than dropping it straight into an assign.
When it doesn’t apply: Fast queries. Async has real
overhead — a task, a message round-trip, an AsyncResult
wrapper, and loading-state markup in your template. If the query returns
in single-digit milliseconds, wrapping it in assign_async
is ceremony that buys you nothing and makes the template noisier.
4.
Use temporary_assigns to shrink the diff for write-once
data
The failure mode: You render a large block of data — a long report, a big rendered article body — and keep it in assigns. LiveView holds it for the life of the process and keeps it in the diff-tracking set, even though it never changes after the first render. Memory per connection climbs for no benefit.
temporary_assigns tells LiveView to render the value
once and then reset it, so it isn’t retained between renders:
def mount(_params, _session, socket) do
{:ok,
assign(socket, :report, Reports.generate()),
temporary_assigns: [report: nil]}
endAfter the first render, @report is nil in
the process state. The rendered HTML stays in the DOM; you just stop
paying to keep the source data resident.
When it doesn’t apply: Anything you read back or
re-render. The moment you need the value again on a later event,
temporary_assigns becomes a footgun — @report
is nil and you’ll be confused for ten minutes. Streams have
largely replaced this pattern for collections; reach for
temporary_assigns for large, singular, write-once blobs,
not lists.
Real-time
5.
Subscribe to PubSub only when connected? — and handle the
fan-in
The failure mode: You call
Phoenix.PubSub.subscribe/2 at the top of
mount. Because mount runs twice, you subscribe
during the dead render too — a process that’s about to be thrown away
gets a subscription, and depending on your setup you can end up with
duplicate handlers or leaked subscriptions. Always gate the subscribe on
the live connection:
def mount(%{"room_id" => room_id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "room:#{room_id}")
end
{:ok, stream(socket, :messages, Chat.list_messages(room_id))}
end
def handle_info({:new_message, msg}, socket) do
{:noreply, stream_insert(socket, :messages, msg, at: -1, limit: -50)}
endNote how this composes with pattern 1: PubSub fans messages
in, and stream_insert with a :limit
keeps the rendered list bounded no matter how long the session runs.
When it doesn’t apply: It always applies if you’re
subscribing in mount — the connected? guard is
non-negotiable there. The judgment call is what you broadcast:
don’t broadcast full structs to a topic with thousands of subscribers if
a lightweight ID-plus-event would let each LiveView fetch what it needs.
Fan-out cost is real.
6. Get
phx-update="stream" and DOM ids right
The failure mode: You use streams but hand-roll the
container, forget phx-update="stream", or put the wrong
id on the row elements. The symptoms are maddening: items
duplicate on insert, deletes don’t remove the right row, reordering
scrambles. Streams are entirely driven by stable DOM ids, and if the ids
don’t line up with what the server computes, the patches land on the
wrong nodes.
The contract: the container needs phx-update="stream",
and each child’s id must be the dom_id the
stream yields.
<table>
<tbody id="users" phx-update="stream">
<tr :for={{dom_id, user} <- @streams.users} id={dom_id}>
<td>{user.name}</td>
</tr>
</tbody>
</table>If your items don’t have a usable id field, set a custom
id generator when you create the stream:
stream(socket, :users, users, dom_id: &"user-#{&1.email}").
Don’t compute ids two different ways in two places — that mismatch is
exactly the bug.
When it doesn’t apply: Non-stream collections. If
you’re rendering a plain :for over a small assign (pattern
1’s escape hatch), you don’t add phx-update="stream" — that
attribute is a promise to the client that this container is
stream-managed, and using it without an actual stream behind it will
misbehave.
Forms
7. Build
forms with to_form/2 and validate at the boundary
The failure mode: You pass a raw changeset or a bare
map into the template and reach into @changeset.changes and
@changeset.errors by hand in the markup. It’s verbose, it’s
easy to get wrong, and you lose the framework’s form helpers. Worse, if
you don’t validate on phx-change, the first time the user
learns their input is invalid is when they hit submit.
to_form/2 wraps a changeset (or map) into a
Phoenix.HTML.Form the components understand, and
phx-change runs your changeset on every keystroke with
action: :validate so errors surface inline:
def mount(_params, _session, socket) do
{:ok, assign(socket, :form, to_form(Accounts.change_user(%User{})))}
end
def handle_event("validate", %{"user" => params}, socket) do
form =
%User{}
|> Accounts.change_user(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :form, form)}
end<.form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email" />
</.form>When it doesn’t apply: It nearly always applies for
changeset-backed forms. The only time I skip to_form is a
trivial single-input control (a search box) where there’s no validation
and no changeset — there, a plain input with a phx-keyup or
phx-change event handler is less machinery.
Navigation and UI
8. Choose
push_patch vs push_navigate deliberately
The failure mode: You use
push_navigate/2 to move between tabs of the same LiveView.
Each click fully unmounts and remounts the LiveView — every async load
restarts, every stream resets, every subscription tears down and
re-establishes. The page flickers and feels heavy, and you’ve thrown
away all your in-process state for what should have been a URL
change.
push_patch/2 stays in the same LiveView and
invokes handle_params/3 — state survives, you just react to
the new params. push_navigate/2 is for crossing into a
different LiveView, and it pays the full remount cost on
purpose.
# Same LiveView, different filter — cheap, state preserved:
{:noreply, push_patch(socket, to: ~p"/orders?status=open")}
# Different LiveView entirely — full remount, intended:
{:noreply, push_navigate(socket, to: ~p"/orders/#{order}")}The heuristic: same live module →
push_patch. Different module → push_navigate.
Getting this wrong doesn’t break correctness, but it’s the difference
between a snappy app and a janky one.
When it doesn’t apply: When you genuinely
want a fresh start — clearing all transient state, resetting a
multi-step flow. There, the remount that push_navigate
forces is a feature, not a cost.
9.
Use Phoenix.LiveView.JS for UI that should never round-trip
to the server
The failure mode: You toggle a dropdown, open a modal, or flip an accordion by sending an event to the server, flipping a boolean assign, and re-rendering. Now every menu open is a network round-trip — latency-bound, fragile on flaky connections, and pointless because the server doesn’t care whether a dropdown is open.
Phoenix.LiveView.JS runs these as client-side commands,
with no server involvement. They’re DOM-patch aware, so the state sticks
across server-driven patches:
<button phx-click={JS.toggle(to: "#menu")}>Menu</button>
<nav id="menu" class="hidden">…</nav>
<button phx-click={JS.add_class("ring-2", to: "#card")}>Highlight</button>Commands like JS.toggle/1, JS.show/1,
JS.hide/1, JS.add_class/2,
JS.remove_class/2, and JS.dispatch/2 cover
most pure-UI interactions. When you do need the server,
JS.push/2 lets you push an event while also running client
commands optimistically.
When it doesn’t apply: Anything the server is the
source of truth for. If opening a panel needs to load data, mark
something read, or persist a preference, that’s a real event — use
phx-click with a handler. JS commands are for
presentational state the server has no stake in.
10.
Reach for live_component only when you need stateful
isolation — usually you don’t
The failure mode: This one’s overkill in the other
direction. Teams reach for live_component (stateful)
reflexively for every reusable chunk, when a plain function component
would do. live_component carries real weight: it has its
own update/2 lifecycle, its own assigns state, its own
handle_event/3, and it requires a unique :id.
Used where you only needed to render markup from inputs, it’s complexity
with no payoff and a slower mental model for the next reader.
The rule: if the component is a pure function of its inputs, use a
function component. Only use
live_component when the component needs to own state the
parent shouldn’t manage, or handle its own events in isolation.
<%!-- Function component: pure render, the default choice --%>
<.user_badge user={@user} />
<%!-- Live component: owns state + handles its own events --%>
<.live_component module={MyAppWeb.CartWidget} id="cart" user={@user} />When it does apply: A self-contained widget with its own form and validation lifecycle, a piece of UI that needs to react to its own events without bubbling everything up to the parent, or a repeated stateful element where you genuinely want isolation. Those are real — they’re just rarer than the reflex suggests.
Safety
11. Know what survives a reconnect — and supervise accordingly
The failure mode: A LiveView process crashes, or the
client’s connection drops and reconnects (laptop sleeps, network blips,
deploy rolls the node). The client automatically re-establishes and
mount runs again from scratch. Any state
you held only in the process’s assigns is gone — the
half-filled wizard, the accumulated scroll position, the “you have
unsaved changes” flag. If your UX assumed that state was durable, it
just silently evaporated.
The pattern is to be deliberate about durability. Anything that must
survive a reconnect lives somewhere outside the process: the database, a
supervised GenServer/ETS table keyed by user or session, or the URL
itself (which is why pattern 8’s push_patch matters —
params survive because they’re in the URL). Process assigns are
ephemeral by design.
def mount(%{"id" => id}, _session, socket) do
# Reconstruct from a durable source on every mount — including reconnects.
# Don't assume prior in-process state exists.
{:ok, assign(socket, :draft, Drafts.get_or_init(id))}
endAnd the corollary from the iron laws: any long-lived process you stand up to hold that state — a per-room presence tracker, a session cache — must be supervised. An unsupervised GenServer holding durable state is a single crash away from losing exactly the thing you moved out of the LiveView to protect.
When it doesn’t apply: Genuinely transient UI state — which tab is active, whether a tooltip is showing. Losing that on reconnect is invisible to the user. Don’t build durable storage for state nobody would miss; reserve the machinery for state whose loss is a bug.
12. Authorize
on every event handler, not just mount
The failure mode: This is the one that turns into a
security incident. You check authorization in mount — the
user can see this page, good. Then you write
handle_event("delete", %{"id" => id}, socket) and call
Orders.delete(id) directly, trusting that because they
reached the page, the action is fine.
It is not fine. The client is fully untrusted. A malicious user can
open the WebSocket and push any event with any payload
— events your UI never renders a button for, ids belonging to other
tenants. Mount authorization gates viewing the page. It does
nothing for the events. Every handle_event that mutates or
reads sensitive data must re-check authorization against the current
user and the specific resource:
def handle_event("delete", %{"id" => id}, socket) do
order = Orders.get_order!(id)
case Bodyguard.permit(Orders, :delete_order, socket.assigns.current_user, order) do
:ok ->
{:ok, _} = Orders.delete_order(order)
{:noreply, stream_delete(socket, :orders, order)}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Not allowed.")}
end
endScope the lookup to the user where you can
(Orders.get_order!(current_user, id)) so you can’t even
load another tenant’s row. Use whatever authorization you already have —
a policy module, a scoped query, a plain function. The non-negotiable
part is that the check happens here, on every event, not only
at the door.
When it doesn’t apply: Never, for sensitive actions. The only events you can skip it on are ones with no security surface at all — a pure-UI event you’d happily let any visitor fire (and those, per pattern 9, often shouldn’t be hitting the server in the first place). When in doubt, authorize.
The meta-pattern
If you squint, eleven of these twelve are the same insight wearing different clothes: the dead render and the connected socket are two different worlds, and the client side of that boundary is hostile.
mount runs twice because of it (patterns 2, 5). You gate
expensive work and subscriptions on connected? because of
it (2, 3, 5). State evaporates across the reconnect boundary because the
process on the other side is new (11). And the client can push anything
across the WebSocket because the boundary is a trust boundary, not just
a transport (12). Streams, async, and JS commands are all,
in part, about deciding which side of that line a piece of work
belongs on — memory and rendering on the client where it scales, truth
and authorization on the server where it’s safe.
Internalize the boundary and most LiveView “weirdness” stops being weird. Forget it, and you’ll keep rediscovering these twelve scars the hard way.
For the data-modeling and concurrency machinery underneath all this, see the Elixir concurrency model and why the BEAM is a natural fit for AI agents. A deeper dive on streaming patterns specifically — backpressure, infinite scroll, and live tables at scale — is coming soon as a dedicated post, along with a companion piece on building RAG on Phoenix.
Quick reference
| # | Pattern | Failure it prevents |
|---|---|---|
| 1 | Streams for unbounded lists | Per-connection memory blowup; fat diffs |
| 2 | No unconditional DB query in mount |
Double query; blocked dead render |
| 3 | assign_async for slow mounts |
Blank page on slow load; mount crash on error |
| 4 | temporary_assigns for write-once data |
Memory retained for data that never changes |
| 5 | PubSub subscribe only when connected? |
Leaked/duplicate subscriptions |
| 6 | phx-update="stream" + correct DOM ids |
Duplicated, mis-deleted, scrambled rows |
| 7 | to_form/2 + phx-change validation |
Hand-rolled forms; late error feedback |
| 8 | push_patch vs push_navigate
deliberately |
Needless full remounts; janky navigation |
| 9 | Phoenix.LiveView.JS for pure UI |
Pointless server round-trips for UI state |
| 10 | live_component only when stateful |
Complexity with no payoff |
| 11 | Know what survives reconnect; supervise | Silent state loss; unsupervised crashes |
| 12 | Authorize every handle_event |
Privilege escalation via crafted events |
Twelve patterns, twelve scars. If even one of these saves you a 2 a.m. memory alert or a security postmortem, the read paid for itself.