Phoenix LiveView State Management: What Lives on the Server and Why It Matters
Phoenix LiveView keeps UI state on the server and pushes DOM diffs over WebSockets. Learn how the mount-to-assigns loop works, where memory and latency limits appear, and how to verify the behavior in your app.
13 Jul 2025, 12:11 UTC

The problem: interactive UI without a JavaScript framework
You need a live counter, a collaborative checklist, or a dashboard that updates when another user acts. The conventional answer is to build a JSON API, pick a client-side framework, and then spend your evenings reconciling server truth with client state. Phoenix LiveView proposes a different default: keep the state on the server, render HTML, and push only the diffs over a WebSocket. That promise is attractive, but it raises an immediate engineering question—if the browser is just a projection of server state, what exactly is stored per user, and what breaks when you scale or refresh?
Thesis
LiveView is not magic; it is an explicit trade of client-side complexity for server-side memory and a round-trip latency budget. When you understand the mount → assigns → handle_event loop, you can build real-time features quickly. When you ignore the memory and refresh implications, every open tab becomes a line item on your server bill.
How the state loop actually works
The examples below assume a modern LiveView release; verify attribute and function names against your installed version. Every LiveView begins in mount/3. This is where you initialize the socket’s state, conventionally stored in the assigns map. LiveView tracks changes to that map between renders. When a user clicks an element annotated with phx-click, the event name travels over the WebSocket to handle_event/3 on the server. You update assigns, LiveView computes what changed in the virtual DOM, and sends a compact binary diff back to the browser.
Two attributes give you fine-grained control over that patching. phx-update can be set to ignore, replace, append, or prepend to tell LiveView how to treat a DOM subtree—useful when a child process or client-side script owns part of the markup. phx-click (and its siblings like phx-change or phx-submit) bind DOM events to server handlers without writing JavaScript glue.
A worked example: a shared counter with PubSub
State that lives only in one process is easy. The interesting case is state shared across users. LiveView integrates with Phoenix PubSub, so a message broadcast from one process can update every connected viewer.
Assume you have a standard Phoenix application where MyApp.PubSub is already started in your supervision tree. Create a LiveView module:
defmodule MyAppWeb.CounterLive do
use MyAppWeb, :live_view
@topic "counter:global"
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, @topic)
end
{:ok, assign(socket, :count, 0)}
end
@impl true
def handle_event("increment", _params, socket) do
new_count = socket.assigns.count + 1
Phoenix.PubSub.broadcast(MyApp.PubSub, @topic, {:new_count, new_count})
{:noreply, socket}
end
@impl true
def handle_info({:new_count, count}, socket) do
{:noreply, assign(socket, :count, count)}
end
end
And a minimal template:
<div>
<p>Count: <%= @count %></p>
<button phx-click="increment">+1</button>
</div>
Run mix phx.server from your project root and open the route in two browser tabs. Clicking the button in either tab triggers handle_event, which broadcasts the new count. The sender receives its own broadcast, so the UI updates through the same handle_info path as other clients. To verify PubSub independently, open a second terminal, run iex -S mix, and execute:
Phoenix.PubSub.broadcast(MyApp.PubSub, "counter:global", {:new_count, 42})
Expected check: every open browser window should jump to 42. Risk: if your PubSub adapter is not configured for clustering, broadcasts will not cross node boundaries; check your config/config.exs adapter settings if you run multiple nodes.
The trade-offs you cannot ignore
Server-side state is convenient, but it is not free.
- Memory scales with connections. Each connected user holds a process with its own
assignsmap. A large list or nested map multiplied by thousands of concurrent users can exhaust BEAM memory. Keep assigns lean; for big collections, paginate or investigate LiveView streams rather than loading everything into state. - Refresh wipes the slate. Because state lives in the LiveView process, a page reload remounts the view and resets
assigns. If a value must survive a refresh, persist it to a database, cache, or signed session parameter before the process dies. - Round trips cost latency. Every interaction is a network hop. For high-frequency gestures—drag-and-drop, canvas animations, or smooth scrolling—a pure server round trip will feel sluggish. LiveView’s escape hatch is a JavaScript client hook via
phx-hook, which lets you own the latency-sensitive part on the client while the server remains the source of truth.
How to verify your implementation
Open your browser’s developer tools and select the Network tab, then filter for WS (WebSocket). Click the button and watch the frames. You should see small binary payloads—the DOM diffs—not full HTML documents. If you see full-page payloads, check that your template is not wrapped in a layout that forces re-renders, and confirm you are using phx-click rather than a raw <a> navigation.
Next, audit your assigns. If you are storing entire database result sets, measure the memory of a single LiveView process under load with your observability tool of choice, then decide whether pagination or streaming is required.
Bottom line
LiveView collapses the frontend/backend boundary for interactive features by moving state to the server. That is a powerful default for forms, dashboards, and collaborative tools. Treat assigns as a carefully budgeted resource, reach for PubSub when multiple users need the same truth, and drop to client hooks only when the round trip genuinely hurts. Start with a counter, watch the diffs in the network tab, and scale your state deliberately.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.