Phoenix LiveView applications have a distinct architecture that differs from traditional request-response web apps. The server holds state, pushes diffs over WebSockets, and the browser is a thin rendering layer. Understanding how the pieces fit together is key to building maintainable LiveView apps.
The big picture
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HEEx Template → minimal JS → renders DOM │ │
│ │ phx-click, phx-submit, phx-change push events │ │
│ │ receives diffs → patches DOM │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ WebSocket │
└─────────────────────────┼───────────────────────────────────┘
│
┌─────────────────────────┼───────────────────────────────────┐
│ Phoenix Server │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ Endpoint │ │
│ │ (Plug pipeline: parsers, session, CSRF, PubSub) │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ Router │ │
│ │ live "/posts", PostLive.Index │ │
│ │ live "/posts/:id", PostLive.Show │ │
│ │ live "/admin", AdminLive.Dashboard │ │
│ └──────────┬──────────────────────────┬───────────────┘ │
│ │ │ │
│ ┌──────────▼──────────┐ ┌───────────▼───────────────┐ │
│ │ LiveView Module │ │ Traditional Controller │ │
│ │ │ │ (for JSON API, etc.) │ │
│ │ mount/3 │ └───────────┬───────────────┘ │
│ │ handle_params/3 │ │ │
│ │ handle_event/3 │ │ │
│ │ handle_info/2 │ │ │
│ │ render/1 │ │ │
│ └──────────┬──────────┘ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Context Modules │ │
│ │ (Blog, Accounts, Catalog — the public API) │ │
│ │ │ │
│ │ • list_posts/1 • create_post/1 │ │
│ │ • get_post!/1 • update_post/2 │ │
│ │ • subscribe/1 • delete_post/1 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ Ecto (Schemas + Repo) │ │
│ │ │ │
│ │ Schemas ──► Changesets ──► Queries ──► Repo │ │
│ │ (Post) (validation) (SQL) (database) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Phoenix.PubSub │ │
│ │ (broadcasts events between processes) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ OTP Supervision Tree │ │
│ │ (Application supervisor manages all processes) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Layer by layer
1. Browser layer
LiveView renders HTML on the server and sends it to the browser. After the initial HTTP render, a WebSocket connection is established. Subsequent interactions send minimal events (phx-click, phx-submit, phx-change) to the server and receive only DOM diffs back.
The browser runs a small JavaScript client (phoenix.js) that:
- Establishes the WebSocket connection
- Sends DOM events to the server
- Applies DOM patches received from the server
- Manages client-side hooks for complex interactions
Key insight: There is no application JavaScript to write for most features. The browser is a display layer.
2. Endpoint
The endpoint is the entry point for all requests. It sets up the Plug pipeline:
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# Static files
plug Plug.Static, at: "/", from: :my_app
# Common plugs
plug Plug.Session, store: :cookie, key: "_my_app_key", signing_salt: "..."
plug Plug.Parsers, parsers: [:urlencoded, :json, :multipart]
# The router is the final plug
plug MyAppWeb.Router
end3. Router
The router directs requests to LiveViews or controllers:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :protect_from_forgery
end
scope "/", MyAppWeb do
pipe_through :browser
# LiveView routes — persistent WebSocket connections
live "/", PageLive
live "/posts", PostLive.Index
live "/posts/new", PostLive.New
live "/posts/:id", PostLive.Show
live "/posts/:id/edit", PostLive.Edit
# Traditional controller routes (for APIs, redirects)
get "/health", HealthController, :index
end
endlive/3 routes automatically handle both the initial HTTP render and the WebSocket upgrade.
4. LiveView module
A LiveView is a GenServer that holds state in socket.assigns and responds to three types of messages:
| Callback | Triggered by | Purpose |
|---|---|---|
mount/3 |
Initial connection | Load initial data, set up subscriptions |
handle_params/3 |
URL change (push_patch, browser nav) | Load data based on URL params |
handle_event/3 |
DOM events (phx-click, phx-submit) | Handle user actions |
handle_info/2 |
Elixir process messages | React to PubSub broadcasts, timers |
render/1 |
Any assign change | Return HEEx template (called automatically) |
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, stream(socket, :posts, Blog.list_posts())}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(id)
{:ok, _} = Blog.delete_post(post)
{:noreply, stream(socket, :posts, Blog.list_posts(), reset: true)}
end
@impl true
def handle_info({:post_created, post}, socket) do
{:noreply, stream_insert(socket, :posts, post, at: 0)}
end
@impl true
def render(assigns) do
~H"""
<ul id="posts" phx-update="stream">
<li :for={{id, post} <- @streams.posts} id={id}>
<%= post.title %>
<button phx-click="delete" phx-value-id={post.id}>Delete</button>
</li>
</ul>
"""
end
end5. Context modules
Contexts are the boundary between your web layer and business logic. LiveViews and controllers call context functions — never Repo directly.
defmodule MyApp.Blog do
@moduledoc "The Blog context."
alias MyApp.Repo
alias MyApp.Blog.Post
def list_posts do
Repo.all(from p in Post, order_by: [desc: :inserted_at])
end
def get_post!(id), do: Repo.get!(Post, id)
def create_post(attrs) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
|> tap_notify(:post_created)
end
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
|> tap_notify(:post_updated)
end
def delete_post(%Post{} = post) do
Repo.delete(post)
end
# PubSub helpers
defp tap_notify({:ok, post}, event) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {event, post})
{:ok, post}
end
defp tap_notify(error, _), do: error
end6. Ecto (Schemas + Repo)
Ecto maps your database to Elixir structs and validates data through changesets:
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
field :published, :boolean, default: false
belongs_to :author, MyApp.Accounts.User
has_many :comments, MyApp.Blog.Comment
timestamps()
end
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :published, :author_id])
|> validate_required([:title, :body])
|> validate_length(:title, min: 3, max: 200)
|> assoc_constraint(:author)
end
end7. PubSub
PubSub is the glue that makes LiveViews reactive. When data changes, contexts broadcast events, and LiveViews react:
# In the context (broadcast)
Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:post_updated, post})
# In the LiveView (subscribe on mount, react in handle_info)
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "posts")
{:ok, socket}
end
def handle_info({:post_updated, post}, socket) do
{:noreply, stream_insert(socket, :posts, post)}
end8. OTP Supervision tree
Phoenix applications are OTP applications. Each LiveView is a process supervised by the LiveView supervisor. PubSub, Repo, and custom GenServers all live in the supervision tree:
MyApp.Application
├── MyApp.Repo (Ecto repo, pool of DB connections)
├── MyApp.PubSub (Phoenix.PubSub)
├── MyAppWeb.Endpoint (Phoenix endpoint)
│ └── Phoenix.LiveView.Socket (per connected user)
├── MyApp.Scheduler (Quantum or Oban for background jobs)
└── MyApp.Workers (custom GenServers, Agents, Tasks)
Data flow patterns
Read flow (user opens page)
1. Browser requests /posts
2. Router matches to PostLive.Index
3. mount/3 loads data via Blog.list_posts()
4. render/1 produces HTML
5. Browser renders the page
6. WebSocket connects (if JS enabled)
7. mount/3 runs again (connected? check) → subscribes to PubSub
8. User sees the page, WebSocket is live
Write flow (user creates a post)
1. User clicks "Save" → phx-submit="save"
2. handle_event/3 receives the form data
3. Calls Blog.create_post(attrs)
4. Blog creates the post via Repo.insert()
5. Blog broadcasts {:post_created, post} on PubSub
6. PostLive.New handle_event redirects to PostLive.Show
7. Any other LiveView subscribed to "posts" receives handle_info
8. Each LiveView's render/1 fires, sending diffs to browsers
Real-time update flow (another user makes a change)
1. User B edits a post → handle_event → Blog.update_post()
2. Context broadcasts {:post_updated, post}
3. User A's LiveView receives handle_info({:post_updated, post})
4. User A's assign/stream is updated
5. render/1 fires → diff sent to User A's browser
6. User A sees the change without refreshing
Directory structure
A well-organized Phoenix LiveView app:
my_app/
├── lib/
│ ├── my_app/ # Business logic (no web dependencies)
│ │ ├── blog/ # Blog context
│ │ │ ├── post.ex # Schema + changeset
│ │ │ └── comment.ex # Schema + changeset
│ │ ├── blog.ex # Blog context API
│ │ ├── accounts/ # Accounts context
│ │ │ └── user.ex
│ │ └── accounts.ex
│ │
│ └── my_app_web/ # Web layer
│ ├── endpoint.ex # Plug pipeline
│ ├── router.ex # Routes
│ ├── components/ # Shared HEEx components
│ │ ├── core_components.ex
│ │ └── layouts.ex
│ └── live/ # LiveView modules
│ ├── post_live/
│ │ ├── index.ex # List posts
│ │ ├── show.ex # Show a post
│ │ └── form_component.ex # Create/edit form
│ └── admin_live/
│ └── dashboard.ex
│
├── priv/
│ └── repo/
│ ├── migrations/ # Ecto migrations
│ └── seeds.exs # Seed data
│
└── test/
├── my_app/ # Context tests
│ └── blog_test.exs
└── my_app_web/
└── live/ # LiveView tests
└── post_live_test.exs
Key architectural decisions
When to use LiveView vs. controllers
| Use LiveView for | Use controllers for |
|---|---|
| Interactive pages (forms, dashboards) | JSON API endpoints |
| Real-time updates | File downloads |
| Any page that benefits from server state | Webhooks |
| CRUD interfaces | Authentication callbacks (OAuth redirect) |
When to extract a LiveComponent
LiveComponents let you split complex LiveViews into independent stateful modules:
# A stateful LiveComponent with its own state and events
defmodule MyAppWeb.PostLive.FormComponent do
use MyAppWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div>
<.form for={@changeset} phx-submit="save" phx-target={@myself}>
<.input field={@changeset[:title]} />
<.input field={@changeset[:body]} type="textarea" />
<button type="submit">Save</button>
</.form>
</div>
"""
end
@impl true
def handle_event("save", %{"post" => post_params}, socket) do
case Blog.create_post(post_params) do
{:ok, post} ->
send(self(), {:post_created, post})
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
endUse LiveComponents when:
- A section of the page has its own state and events
- You need to isolate update frequency (e.g., a live clock)
- The same form appears on multiple pages (DRY)
Keep inline in the LiveView when:
- The event handlers need access to parent assigns
- The UI is simple enough not to warrant separation
State management rules
| Rule | Why |
|---|---|
| Keep assigns minimal | Less data sent over the wire on each update |
Use temporary_assigns for large lists |
Resets between renders, reducing payload |
Use streams for large collections |
Tracks only additions/removals, not full lists |
| Don’t store what you can compute | Derive values in render/1, not in assigns |
Load data in mount or handle_params |
Not in render/1 (it runs on every update) |
Context boundaries
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Accounts │ │ Blog │ │ Catalog │
│ │ │ │ │ │
│ User │ │ Post │ │ Product │
│ Credential │ │ Comment │ │ Category │
│ Token │ │ Tag │ │ Variant │
│ │ │ │ │ │
│ list_users/1 │ │ list_posts/1 │ │ list_products/1 │
│ get_user!/1 │ │ create_post/1 │ │ get_product!/1 │
│ register/1 │ │ delete_post/1 │ │ search/2 │
└──────────────────┘ └──────────────────┘ └──────────────────┘
▲ ▲ ▲
│ │ │
└────────────────────────┼─────────────────────────┘
│
LiveViews call contexts
Contexts call Repo
Contexts never call other contexts*
*Cross-context calls are acceptable if one context depends on another’s public API, but avoid circular dependencies. Prefer PubSub for loose coupling.
Common patterns
The search-and-filter LiveView
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, socket |> assign(page: 1, per_page: 20) |> assign_posts()}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, socket |> assign(page: String.to_integer(params["page"] || "1")) |> assign_posts()}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
{:noreply, socket |> assign(query: query, page: 1) |> assign_posts()}
end
@impl true
def handle_event("change_page", %{"page" => page}, socket) do
{:noreply, push_patch(socket, to: ~p"/posts?page=#{page}")}
end
defp assign_posts(socket) do
posts = Blog.list_posts(query: socket.assigns[:query], page: socket.assigns.page, per_page: socket.assigns.per_page)
stream(socket, :posts, posts, reset: true)
end
endThe edit form with optimistic updates
defmodule MyAppWeb.PostLive.Show do
use MyAppWeb, :live_view
@impl true
def mount(%{"id" => id}, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(MyApp.PubSub, "posts:#{id}")
end
{:ok, assign(socket, post: Blog.get_post!(id))}
end
@impl true
def handle_info({:post_updated, post}, socket) do
{:noreply, assign(socket, post: post)}
end
@impl true
def handle_event("save", %{"post" => attrs}, socket) do
case Blog.update_post(socket.assigns.post, attrs) do
{:ok, post} ->
{:noreply, socket |> put_flash(:info, "Saved!") |> assign(post: post)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
endBackground work with handle_info
defmodule MyAppWeb.ReportLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Process.send_after(self(), :generate_report, 0)
end
{:ok, assign(socket, status: :loading, report: nil)}
end
@impl true
def handle_info(:generate_report, socket) do
# Kick off async work
Task.start(fn ->
report = Blog.generate_expensive_report()
send(self(), {:report_ready, report})
end)
{:noreply, assign(socket, status: :processing)}
end
@impl true
def handle_info({:report_ready, report}, socket) do
{:noreply, assign(socket, status: :done, report: report)}
end
endTrade-offs
| Pro | Con |
|---|---|
| No JavaScript for most features | Server holds state per connection (memory cost) |
| Real-time by default | Not ideal for offline-first apps |
| Unified Elixir stack | WebSocket requirement (falls back to HTTP) |
| Fast initial paint (server-rendered HTML) | Complex client interactions still need hooks |
| Easy to reason about (single process per view) | Horizontal scaling requires sticky sessions or PubSub |
When to use LiveView vs. alternatives
| Scenario | Recommendation |
|---|---|
| CRUD admin, dashboards, forms | LiveView |
| Real-time notifications, chat | LiveView + PubSub |
| Public JSON API | Controllers |
| Offline-first PWA | Controllers + JS |
| Complex client-side visualization | LiveView + hooks, or SPA |
| File upload with progress | LiveView (built-in support) |