Skip to content

Web Development · Backend Frameworks

Phoenix LiveView: Real-Time Web Features Without Writing a Line of JavaScript

Phoenix LiveView lets you build real-time, interactive UIs with server-side Elixir and minimal JavaScript. Here's how it works, where it shines, and what you give up compared to React.

Anurag Verma

Anurag Verma

8 min read

Phoenix LiveView: Real-Time Web Features Without Writing a Line of JavaScript

Sponsored

Share

Most real-time web features follow the same pattern: a React frontend, a WebSocket server, some state management library, and a lot of code synchronizing what the server knows with what the browser shows. It works, but there’s a lot of surface area: two runtimes, two state models, coordination logic.

Phoenix LiveView takes a different path. The server renders HTML, sends it over a persistent WebSocket connection, and patches the DOM when state changes. You write Elixir on the server, and the browser handles the DOM diffing. For the right use cases, it cuts the complexity down significantly.

This post is for developers already comfortable with React or another frontend framework who want to understand what the LiveView model offers and where it falls short.

What Elixir and Phoenix Are

Before LiveView, a quick orientation:

Elixir is a functional language built on the Erlang VM (BEAM). It inherits Erlang’s concurrency model: lightweight processes, message passing, and fault tolerance built into the runtime. A typical Elixir application runs millions of processes concurrently. Each process has its own memory and fails independently. One process crash doesn’t bring down others.

Phoenix is Elixir’s web framework. It’s fast (handling hundreds of thousands of WebSocket connections per server is routine), and it uses channels as the abstraction for persistent connections. LiveView is built on top of Phoenix Channels.

The LiveView Model

When a user loads a LiveView page, this happens:

  1. The server renders the initial HTML and sends it in the HTTP response (fast first paint, crawlable content).
  2. The page loads a small JavaScript client (~50KB) that opens a WebSocket back to the server.
  3. The server keeps a process alive for this connection. This process holds the UI state.
  4. When state changes (user clicks a button, a database record updates, a timer fires), the server re-renders just the changed parts and sends a minimal diff to the client.
  5. The JavaScript client applies the diff to the DOM.

From the developer’s perspective, you write one template and one Elixir module. The framework handles the WebSocket, the diffing, and the DOM updates.

# lib/my_app_web/live/counter_live.ex
defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  def handle_event("increment", _params, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("decrement", _params, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def render(assigns) do
    ~H"""
    <div class="counter">
      <button phx-click="decrement">-</button>
      <span class="value"><%= @count %></span>
      <button phx-click="increment">+</button>
    </div>
    """
  end
end

The phx-click attribute tells LiveView to send the "increment" event to the server when clicked. The server handles it, updates the socket state, and the framework sends a diff back. No JavaScript written.

A Live Search Example

A more practical case: a search input that filters results in real-time as the user types.

defmodule MyAppWeb.SearchLive do
  use MyAppWeb, :live_view
  alias MyApp.Products

  def mount(_params, _session, socket) do
    {:ok, assign(socket, query: "", results: [], loading: false)}
  end

  def handle_event("search", %{"query" => query}, socket) do
    socket = assign(socket, query: query, loading: true)
    # In a real app, use Task.async or Phoenix.PubSub for async search
    results = Products.search(query)
    {:noreply, assign(socket, results: results, loading: false)}
  end

  def render(assigns) do
    ~H"""
    <div>
      <input
        type="text"
        value={@query}
        phx-input="search"
        phx-debounce="300"
        placeholder="Search products..."
      />

      <%= if @loading do %>
        <div class="spinner" />
      <% else %>
        <ul>
          <%= for product <- @results do %>
            <li>
              <strong><%= product.name %></strong>
              <span><%= product.price %></span>
            </li>
          <% end %>
        </ul>
      <% end %>
    </div>
    """
  end
end

The phx-debounce="300" attribute delays sending the event until the user stops typing for 300ms. The debouncing is handled client-side by the LiveView JavaScript library. No custom debounce code.

Real-Time Updates via PubSub

LiveView integrates tightly with Phoenix PubSub. When a record changes anywhere in the system (another user’s action, a background job, an external event), you can push the update to all subscribers:

defmodule MyAppWeb.OrdersLive do
  use MyAppWeb, :live_view
  alias MyApp.Orders
  alias Phoenix.PubSub

  def mount(_params, %{"user_id" => user_id}, socket) do
    if connected?(socket) do
      # Only subscribe after the WebSocket is open (not during HTTP render)
      PubSub.subscribe(MyApp.PubSub, "orders:#{user_id}")
    end

    orders = Orders.list_for_user(user_id)
    {:ok, assign(socket, orders: orders, user_id: user_id)}
  end

  # Called when a PubSub message arrives
  def handle_info({:order_updated, order}, socket) do
    updated_orders =
      socket.assigns.orders
      |> Enum.map(fn o -> if o.id == order.id, do: order, else: o end)

    {:noreply, assign(socket, orders: updated_orders)}
  end

  def render(assigns) do
    ~H"""
    <table>
      <tbody>
        <%= for order <- @orders do %>
          <tr id={"order-#{order.id}"}>
            <td><%= order.id %></td>
            <td><%= order.status %></td>
            <td><%= order.total %></td>
          </tr>
        <% end %>
      </tbody>
    </table>
    """
  end
end

When a background job updates an order status and broadcasts to "orders:user_123", every LiveView connected for that user gets the update and re-renders the row. The browser sees a targeted DOM patch, not a full page reload.

Forms and Validation

LiveView has built-in form handling that gives you live validation feedback without submitting the form:

defmodule MyAppWeb.RegistrationLive do
  use MyAppWeb, :live_view
  alias MyApp.Accounts
  alias MyApp.Accounts.User

  def mount(_params, _session, socket) do
    changeset = Accounts.change_user(%User{})
    {:ok, assign(socket, form: to_form(changeset))}
  end

  def handle_event("validate", %{"user" => params}, socket) do
    changeset =
      %User{}
      |> Accounts.change_user(params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, form: to_form(changeset))}
  end

  def handle_event("save", %{"user" => params}, socket) do
    case Accounts.create_user(params) do
      {:ok, _user} ->
        {:noreply, push_navigate(socket, to: "/dashboard")}

      {:error, changeset} ->
        {:noreply, assign(socket, form: to_form(changeset))}
    end
  end

  def render(assigns) do
    ~H"""
    <.form for={@form} phx-change="validate" phx-submit="save">
      <.input field={@form[:email]} type="email" label="Email" />
      <.input field={@form[:password]} type="password" label="Password" />
      <.button type="submit">Create account</.button>
    </.form>
    """
  end
end

phx-change="validate" sends the form values to the server on every change. The server runs the changeset validation and sends back the errors. The form shows inline error messages as the user types. No client-side validation code, no synchronization between client and server validation rules.

What LiveView Gives Up Compared to React

LiveView is compelling for the use cases it fits, but the tradeoffs are real.

Network round-trip on every interaction. Every click, input, and event goes to the server. If the user is on a slow connection, interactions feel sluggish. React can handle most interactions offline and sync later.

JavaScript ecosystem access is limited. You can add JavaScript hooks for things LiveView can’t do (rich text editors, file uploads with previews, canvas drawing), but you’re working against the grain. A complex drag-and-drop or animation-heavy interface is genuinely harder in LiveView.

Server resources per connection. Each connected LiveView is a running Elixir process. The BEAM handles millions of processes, so this isn’t a concern in most cases, but it’s a different mental model from stateless HTTP endpoints.

Smaller ecosystem. Phoenix and Elixir have good libraries, but they’re a fraction of what the Node.js ecosystem offers. If you need a specific third-party integration, you’ll check if an Elixir library exists before choosing LiveView.

Learning curve. Elixir’s functional style, pattern matching, and process model take a few weeks to get comfortable with if you’re coming from JavaScript.

Where LiveView Wins

Admin interfaces and internal tools. Data tables, form editing, real-time dashboards. These don’t need the full power of React and benefit from LiveView’s simplicity.

SaaS dashboards with real-time data. Order status pages, analytics views, notification feeds. LiveView’s PubSub integration makes these straightforward.

Collaborative features. Shared whiteboards, multi-user forms, live presence indicators. The process model maps naturally to “who is connected and what are they doing.”

Teams with Elixir expertise. If your backend team already knows Elixir, adding LiveView means one language instead of two. That’s a significant advantage for small teams.

HTMX vs LiveView

Both approaches use server-rendered HTML updates instead of a JavaScript SPA, but they’re meaningfully different.

HTMX is a JavaScript library you add to existing HTML. It works with any backend: Rails, Django, FastAPI. It enhances specific elements with dynamic behavior via attributes. It’s simpler and easier to adopt incrementally.

LiveView is a framework with its own opinions about state management and process lifecycle. It’s more opinionated and more capable for complex real-time scenarios. The persistent connection and server-side process model enable patterns that HTMX can’t do cleanly.

If you have an existing Ruby or Python backend, HTMX is the more practical path. If you’re starting fresh and want real-time-first, LiveView with Elixir is worth evaluating seriously.

Getting Started

The quickest way to try it:

# Install Elixir (macOS)
brew install elixir

# Install Phoenix project generator
mix archive.install hex phx_new

# Create a new project with LiveView
mix phx.new my_app --live

cd my_app
mix deps.get
mix ecto.create
mix ecto.migrate
mix phx.server

Visit http://localhost:4000 and you’ll see the default Phoenix welcome page. The generated app includes a LiveView counter at /dev/dashboard you can inspect.

The Phoenix documentation is genuinely excellent. The LiveView guides walk through the programming model clearly. The official HexDocs for Phoenix.LiveView are worth reading even before you start a project, just to understand what the framework handles for you and what you’re responsible for.

For teams evaluating it: build a small internal tool before committing to LiveView for a customer-facing product. The model clicks after a couple of days of hands-on work in a way it doesn’t from reading about it.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored