What happens when you visit a LiveView URL?

Learn Phoenix LiveView is the comprehensive tutorial that teaches you everything you need to build a complex, realistic, fully-featured web app with Phoenix LiveView. Click here to learn more!

A classic job interview question is: “What happens when you type a URL into your browser’s address bar and hit Enter?” In my last post, I explained how it works in a Phoenix app: Phoenix initializes a %Plug.Conn{} representing the incoming HTTP request, then passes it through a chain of functions returning a new %Plug.Conn{} with the response.

For a static page rendered by a controller, that’s all. But if the page is a LiveView (arguably Phoenix’s killer feature), we’re only just beginning. In this post I’ll continue the story and explain how pressing “Enter” in your address bar can make Phoenix open a websocket and mount a LiveView.

The code

For illustrative purposes, let’s scaffold a LiveView in the same Example app from the previous post. Generate a set of LiveViews for a resource called Product:

$ mix phx.gen.live Store Product products name:string

Follow the generator’s instructions by adding routes to the router:

 defmodule ExampleWeb.Router do
   use ExampleWeb, :router

   

   scope "/", ExampleWeb do
     pipe_through :browser

     get "/", PageController, :home

     resources "/users", UserController
+
+    live "/products", ProductLive.Index, :index
+    live "/products/new", ProductLive.Index, :new
+    live "/products/:id/edit", ProductLive.Index, :edit
+
+    live "/products/:id", ProductLive.Show, :show
+    live "/products/:id/show/edit", ProductLive.Show, :edit
   end

   

Then run the new migration file:

$ mix ecto.migrate
08:33:06.318 [info] == Running 20240929072955 Example.Repo.Migrations.CreateProducts.change/0 forward

08:33:06.319 [info] create table products

08:33:06.343 [info] == Migrated 20240929072955 in 0.0s

Start the server and visit localhost:4000/products. It renders the ProductLive.Index LiveView:

Because this page is a LiveView, a websocket is now open between browser and server. LiveView lets us add rich, interactive UI without any custom Javascript:

So how does it work?

The initial HTTP request

When you hit “Enter” in the address bar, your browser has no idea that the page will be served by LiveView. How could it know? It makes an HTTP GET request like it would for any other URL.

Initially, Phoenix handles this like any other HTTP request: it initializes a %Plug.Conn{} then passes it through a list of plugs as defined in the Endpoint and Router. The path GET /products matches this route defined by live/3:

 # lib/example_web/router.ex
 defmodule ExampleWeb.Router do
   

   scope "/", ExampleWeb do
     pipe_through :browser

     

     live "/products", ProductLive.Index, :index
     

So the %Plug.Conn{} is passed through the :browser pipeline. Overall, the %Plug.Conn{} gets passed through the same chain of functions as before:

conn
|> Plug.Static.call()
|> Phoenix.LiveDashboard.RequestLogger.call()
|> Phoenix.LiveReloader.call()
|> Phoenix.CodeReloader.call()
|> Phoenix.Ecto.CheckRepoStatus.call()
|> Plug.RequestId.call()
|> Plug.Telemetry.call()
|> Plug.Parsers.call()
|> Plug.MethodOverride.call()key
|> Plug.Head.call()
|> Plug.Session.call()
|> Phoenix.Controller.accepts()
|> Plug.Conn.fetch_session()
|> Phoenix.LiveView.Router.fetch_live_flash()
|> Phoenix.Controller.put_root_layout()
|> Phoenix.Controller.protect_from_forgery()
|> Phoenix.Controller.put_secure_browser_headers()

See my previous post if you’re not sure how this work.

The client is still waiting for an HTTP response, but this time it won’t be rendered by a controller. It’ll be rendered by ProductLive.Index.

Want more posts like this in your inbox?

No spam. Unsubscribe any time.

Mounting the LiveView

First off, Phoenix mounts the LiveView by calling its mount/3 function:

 # lib/example_web/live/product_live/index.ex
 defmodule ExampleWeb.ProductLive.Index do
   use ExampleWeb, :live_view

   

   @impl true
   def mount(_params, _session, socket) do
     {:ok, stream(socket, :products, Store.list_products())}
   end

   

The argument socket is an instance of %Phoenix.LiveView.Socket{}, representing the websocket connection between client and server. (The actual websocket isn’t open yet, but we’ll discuss that in a moment.)

We no longer have access to the %Plug.Conn{}, but the %Plug.Conn{}‘s data will still be used when returning the HTTP response. For example, any HTTP headers set by the Endpoint or a router pipeline will be included in the response returned by the LiveView.

mount/3 must return a tuple {:ok, socket}, where socket is an updated %Phoenix.LiveView.Socket{} containing whatever data is necessary to render the initial LiveView. In this case, we’re loading all products then streaming them, but the details don’t matter here.

After mount/3, LiveView calls handle_params/3 to make additional updates to the socket if needed. (The distinction between mount/3 and handle_params/3 doesn’t matter for the purposes of this post):

 # lib/example_web/live/product_live/index.ex
 defmodule ExampleWeb.ProductLive.Index do
   

   @impl true
   def handle_params(params, _url, socket) do
     {:noreply, apply_action(socket, socket.assigns.live_action, params)}
   end

   

Finally, LiveView must render its template, written in HEEx (HTML + Embedded Elixir). For ProductLive.Index the template is defined at lib/example_web/live/product_live/index.html.heex:

<.header>
  Listing Products
  <:actions>
    <.link patch={~p"/products/new"}>
      <.button>New Product</.button>
    </.link>
  </:actions>
</.header>


Equivalently, we could have defined a function render/1 which renders HEEx with a ~H sigil:

 # lib/example_web/live/product_live/index.ex
 defmodule ExampleWeb.ProductLive.Index do
   

   # This function, if it existed, would be exactly equivalent to defining the
   # template in a separate .html.heex file:
   @impl true
   def render(assigns) do
     ~H"""
     <.header>
       Listing Products
       <:actions>
         <.link patch={~p"/products/new"}>
           <.button>New Product</.button>
         </.link>
       </:actions>
     </.header>

     
     """
   end

The HTML rendered by this HEEx is sent as the response to the original HTTP request. See for yourself: open your browser’s network inspector, refresh the page, and look for the GET request to /products. You’ll see it received a 200 response with the rendered HTML:

We still haven’t opened the websocket that makes LiveView work. But we’ve rendered a static page of HTML like so:

Opening the websocket

HTTP is a request-response protocol. The browser sends a request, the server responds, then it’s over. If the browser wants more it needs to send another request. And if the server has something new to say, it can’t initiate the conversation, and can only wait for the browser’s next request.

Websockets work differently. Unlike HTTP, a websocket allows full duplex communication, where either side can send a message to the other at any time. Once a websocket is open between browser and server, it stays open until one side closes it (e.g. user closes the browser tab).

Websockets are how LiveView works its magic. Once the initial HTTP request has rendered its first response, it opens a websocket connection over which all future communication happens.

Back in the network inspector, you can see a websocket connection has been opened to the URL ws://localhost:4000/live/websocket:

Since we’re in dev mode, there’s also a websocket open to the path /phoenix/live_reload/socket/websocket, which is used for code reloading (i.e. automatically updating your app when you change the code.) It’s not opened in tests or production, and we won’t discuss it further here.

Look again at the HTML that was rendered for the original GET request. It contains a <script> tag:

Without getting into the full details of Phoenix asset compilation: this tag loads your app’s compiled app.js file, which includes the Javascript from assets/js/app.js in your repo. Among other things, that Javascript opens a websocket connection:

 // assets/app.js
 …
 import {Socket} from "phoenix"
 import {LiveSocket} from "phoenix_live_view"
 …

 let liveSocket = new LiveSocket("/live", Socket, {
   longPollFallbackMs: 2500,
   params: {_csrf_token: csrfToken}
 })

 …

 // connect if there are any LiveViews on the page
 liveSocket.connect()

Back in endpoint.ex, you can see that in addition to all the plugs, the server calls socket/3 with first argument "/live":

 # lib/example_web/endpoint.ex
 defmodule ExampleWeb.Endpoint do
   use Phoenix.Endpoint, otp_app: :example

   

   socket "/live", Phoenix.LiveView.Socket,
     websocket: [connect_info: [session: @session_options]],
     longpoll: [connect_info: [session: @session_options]]

   

This defines a websocket endpoint at /live/websocket, which is what our browser is currently connected to as seen in the network inspector. If it’s not possible to open a websocket (true for around 2-3% of servers according to Elixir creator José Valim), it also provides an endpoint /live/longpoll so that the client can fall back to long polling.

mount/3 is called twice

If you stick some debugging code in ProductLive.Index.mount/3, you might notice something interesting:

 # lib/slax_web/live/produce_live/index.ex
 defmodule SlaxWeb.ProductLive.Index do
   

   @impl true
   def mount(_params, _session, socket) do
+    IO.puts("mounting")
     {:ok, stream(socket, :products, Store.list_products())}
   end

   @impl true
   def handle_params(params, _url, socket) do
+    IO.puts("handling params")
     {:noreply, apply_action(socket, socket.assigns.live_action, params)}
   end

   

Refresh the page and look in your server logs:

[info] GET /productsproducts
mounting
…
handling params
…
[info] Sent 200 in 13ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 13µs
…
mounting
…
handling params
…

As you can see, mount/3 and handle_params/3 are called twice when the page loads. (The template is rendered twice too, as you can see if you e.g. stick <% IO.inspect("rendering") %> somewhere in the index.html.heex template.) They’re called once for the initial HTTP request, then again after the websocket has connected.

You can check the difference by calling connected?/1:

   @impl true
   def mount(_params, _session, socket) do
-    IO.puts("mounting")
+    IO.puts("mounting (connected: #{connected?(socket)})")
     {:ok, stream(socket, :products, Store.list_products())}
   end

   @impl true
   def handle_params(params, _url, socket) do
-    IO.puts("handling params")
+    IO.puts("handling params (connected: #{connected?(socket)})")
     {:noreply, apply_action(socket, socket.assigns.live_action, params)}
   end

Refresh the page and check the logs again.

[info] GET /products
mounting (connected: false)
…
handling params (connected: false)
…
[info] Sent 200 in 13ms
[info] CONNECTED TO Phoenix.LiveView.Socket in 13µs
…
mounting (connected: true)
…
handling params (connected: true)

By rendering the initial page of static HTML, LiveView gives the user a visual response as quickly as possible. The HTML also contains the <script> tag to load the Javascript that opens the websocket. Then once the websocket is open, LiveView calls mount/3 again with the connected socket and re-renders. This lets you perform any additional initialization that requires a connected socket.

With a websocket now open, LiveView waits for an event such as user interaction:

And as you interact with the page, you can see messages being sent over the websocket in both directions.

LiveView keeps the websocket open when possible. If, for example, you navigate to a new page with push_navigate, there’s no HTTP request: LiveView re-uses the existing websocket[1], and only calls mount/3 once, with a connected socket for the new page’s LiveView. Re-using the websocket saves resources as it avoids the overhead of a new HTTP request.

So that’s how a LiveView mounts. The above diagram doesn’t come close to including the full LiveView lifecycle - many things can happen after mounting, such as navigation with navigate or patch, events triggered by user interaction or pushed from hooks, messages handled with handle_info/3 and more. But you can learn about all of that and more from my course at PhoenixLiveView.com.

Want more posts like this in your inbox?

No spam. Unsubscribe any time.