How Phoenix LiveView works. A Beginner's Guide to understanding Phoenix LiveView.
Sep 29, 2024
7 min read
In the old days of web development, interacting with a web application meant reloading the entire page every time you wanted to update the user interface. This was slow, inefficient, and made for a poor user experience. Any interaction with the website, no matter how small it was, would require a round trip to the server, which would then generate a new HTML page and send it back to the client, usually a web browser. Then, the web browser would load the new page from zero, to show the updated content.
From a few years back to now, interactive web applications have been built using JavaScript frameworks like React, Angular, or Vue. These frameworks allow developers to build dynamic web applications that can update the user interface without reloading the page. The round trip to the server is still there, but only to fetch the data needed to update the user interface. Now, the server, instead of returning HTML, usually returns only the information needed to the frontend as a JSON document. The user interface is then updated using JavaScript, which is executed in the web browser. JavaScript then updates the DOM of the page and only the affected component changes, without requiring a full page load.
As you probably know by your experience as a developer, this approach does the work, but it has its own set of problems. The most common one is that you need to write and maintain two separate codebases: one for the frontend and one for the backend. This can be a problem because you need to keep both codebases in sync, which can be a source of bugs and inconsistencies. Also, you need to know two different programming languages and frameworks, which can be a barrier for some developers. Yes, you can use node for the backend, which will allow to use the same language for both codebases, but still even then some things has very different considerations.
What is Phoenix LiveView?
Phoenix LiveView is an elixir library that provides a new approach to building interactive web applications that aims to solve these problems. It allows you to build interactive web applications using only Elixir and Phoenix, without writing a single line of JavaScript. It achieves that by using server-side rendering and WebSockets.
LiveViews run in a Phoenix server, which can scale to handle millions of WebSocket connections, and it has built-in presence tracking so it knows who's connected, and a built-in PubSub system for broadcasting real-time updates to LiveViews. And all this rests on a rock-solid foundation of Elixir, OTP, and Erlang.
So it provides an unrivaled stack for building massively scalable, multi-user, interactive, real-time distributed web apps faster and with less code.
How does Phoenix LiveView work?
A LiveView page is initially rendered as a static HTML page via a request-response cycle (the browser sending the habitual GET
request to the server to load a page). Then a persistent WebSocket connection is automatically opened between the browser and a stateful LiveView process running on the server. This WebSocket connection is opened by a JavaScript worker loaded on the initial page load and it's bundled with LiveView. We'll go back to it later.
And what about the interaction? Any interaction on the web application triggers events that are then pushed down the WebSocket to the LiveView process, which causes its state to change. After those state changes, the LiveView process only re-renders the parts of the page that are affected. Those HTML diffs are then sent back to the browser via the WebSocket as a response. Then, our JavaScript worker patches the DOM.
All this happens out of the box. So as the LiveView process receives events from the GUI, changes state, and re-renders, everything is automatically kept in sync without having to write any JavaScript or manage WebSockets.
LiveView Life Cycle
Let's walk through the life cycle of a LiveView application. When a user initially browses to the website, a regular HTTP GET request is sent to the server, and we have a live route declared in the router for that.
defmodule MyAppWeb.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
scope "/", MyAppWeb do
live "/counter", CounterLive
end
end
That route indicates that the CounterLive
module should be used to handle the request. The CounterLive module is a LiveView module that is responsible for rendering the page and handling events.
defmodule MyAppWeb.CounterLive do
# In Phoenix v1.6+ apps, the line is typically: use MyAppWeb, :live_view
use Phoenix.LiveView
def render(assigns) do
~H"""
<div>
Current value: <%= @counter %>
<button phx-click="inc_counter">Increment counter</button>
</div>
"""
end
def mount(_params, _session, socket) do
counter = 0
{:ok, assign(socket, :counter, counter)}
end
def handle_event("inc_counter", _params, socket) do
{:noreply, update(socket, :counter, &(&1 + 1))}
end
end
Then, in the CounterLive
module, the mount
callback is invoked, which assigns the initial state to the socket. In this case, sets its value to zero. Then render
is automatically invoked with the state
that was assigned to the socket in mount
, and a fully-rendered HTML page is sent back to the browser as a regular HTTP response. Handling the initial request in this way has a few important benefits.
- First, the response is super quick.
- Second, you get a fully-rendered, meaningful HTML page, even if JavaScript is disabled in the browser. And so the initial LiveView page is search engine friendly.
Nothing too surprising so far, but here's where things get interesting. When the initial page is loaded, it also loads a sliver of JavaScript. It's an app.js
, which opens a persistent WebSocket connection to the server (do you remember I already told you about a JavaScript worker?). It's at this point that a stateful LiveView process is spawned. Mount is then invoked again, this time inside of the stateful process, and initializes the state of that process by assigning values to the socket. Then as you probably already guessed, render is also invoked again to render HTML content for that state. But this time, it's not the raw HTML that's pushed back to the browser over the WebSocket, as you might expect. It's actually something more intriguing and efficient.
Let's focus on this section of the HEEx template
~H"""
<div>
Current value: <%= @counter %>
<button phx-click="inc_counter">+</button>
</div>
"""
Since the assigned counter value is interpolated in this template, we have one dynamic value (@counter
), a value that may or may not have changed, and the rest of the template is static. It will never change. So, LiveView splits the template into parts, the stuff that's dynamic and the stuff that's static. The dynamic values evaluates to 0
since that's the initial counter. You can think of this value as being at position or
index zero of the template, and all the static and dynamic parts are pushed to the browser over the WebSocket. Then the JavaScript provided by LiveView weaves the static and dynamic parts together.
Splitting the rendered content into static and dynamic parts really pays off when handling events. For example, when we click the button to increment the counter, an inc_counter
event is pushed down the WebSocket to the LiveView process and gets handled by a matching handle_event
callback. A new value of counter + 1
is assigned to the socket, and whenever a LiveView state changes, the render function is automatically called. Since handling the on event only changed the counter value, only these template expressions need to be evaluated. If the LiveView had other pieces of state rendered by the template, they would only be evaluated if they changed when the counter was incremented.
So what's sent to the browser this time? Well, the static parts aren't sent again. They're already cached in the browser. Only the dynamic value that changed or the diffs get sent. So, as before, all the LiveView JS needs to do is zip the static and dynamic parts together, and then it uses the morphdom library to efficiently patch the DOM to increase the counter.
But how will this scale?
It's a fair question, especially if you're new to the Elixir/Phoenix platform. Since each stateful LiveView runs in a separate process, depending on the number of users you could have thousands, hundreds of thousands, or even millions of processes. And all the communication happens over WebSockets.
A lot of systems would buckle under these conditions, but Elixir and Phoenix are uniquely suited for it. And by riding atop this battle-tested platform, LiveView is in a league of its own.
The Road to 2 Million Websocket Connections has a lot of juicy details.