Phoenix LiveView Application Architecture

Understand the architecture of a Phoenix LiveView application — how endpoints, routers, LiveViews, contexts, and Ecto fit together into a cohesive system.

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
end

3. 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
end

live/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
end

5. 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
end

6. 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
end

7. 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)}
end

8. 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
end

Use 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
end

The 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
end

Background 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
end

Trade-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)

Next: LiveView API Reference · Ecto & Contexts Reference