gio.wtf

Complete Guide to LiveView Reconnects

Introduction

Phoenix LiveView is a server-driven frontend framework with unique tradeoffs, one of which is keeping a stateful client-server connection. While it allows for extremely lightweight payloads, it does not come without challenges.

In this article I’ll cover the issues associated with reconnections, how to reproduce, and fix them.

The problem

LiveView keeps a process alive in the server for as long as the browser doesn’t shutdown the websocket connection. When this process dies, all of its state is garbage collected. Ideally this would only happen if the user closes the browser tab.

In practice, the connection can drop unpredictably due to network issues, battery saving settings, locking mobile screen or having too many tabs open.

Losing all of the server state due to one of those reasons becomes a problem if mounting the LiveView again won’t produce the exact same state. In the next sections I’ll show how to reproduce reconnections locally, the problematic cases, and how to fix them.

Reproducing locally

The most conveninent way to test reconnection issues locally is to force the LiveView to crash. This works because the connection goes both ways, when browser disconnects, the LiveView is shut down and vice versa.

You can do it easily by triggering a phx-click event and not handle it:

~H"""
<.button phx-click="event-no-liveview-is-handling">
  Crash!
</.button>
"""

This blog is powered by LiveView. Feel free to crash it:

Harmless reconnections

Let’s start by examining when reconnections are free of issues by recapping how state initialisation works.

The mount/3 callbacks creates the initial state. It gets executed at least twice before the page becomes interactive: once in a HTTP request, then another when websocket connects.

The second mount is actually agnostic to the transport layer
def mount(params, lv_session, socket) do
  {:ok, initialise_a_bunch_of_state(socket)}
end

The connected?1 function it’s possible handle both mounts differently.

def mount(params, lv_session, socket) do
  {:ok, 
    if not connected?(socket) do
      assign_cheap_data(socket)
    else
      # Avoid subscriptions because first
      # mount won't receive messages anyway
      subscribe_to_realtime_updates()

      # Avoid expensive reads for faster page load
      assign_expensive_data(socket)
    end}
end

The mount/3 callback is called again on every reconnect, in addition to the first two. Server state is discarded between all of them: disconnected mount, connected mount and reconnect mount.

While initial state can be safely discarded, the same is be problematic after users modify it. Losing it will cause the user interface to revert back to initial state, like erasing form inputs and resetting wizards to intial step.

The solution

The solution is straight-forward: preserve the state on the browser any state you can’t recreate so you can recover when needed.

We’ll see how this translates to implementation details. First for the specific case of form state, then a generalised implementation for everything else.

Recovering HTML Forms

For HTML forms the user-driven state is already persisted in the browser in the form of input element values.

Brief recap of how LiveView forms work

Working with forms in LiveView requires setting the initial form state:

def mount(params, _session, socket) do
  {:ok, assign(socket, :form, User.changeset() |> to_form())}
end

Rendering the form inputs:

def render(assigns) do
  ~H"""
  <.form for={@form} phx-change="validate">
    <.input field={@form[:nickname]} />
  </.form>
  """
end

And updating the server-side form state on user interaction:

def handle_event("validate", params, socket) do
  {:noreply, assign(socket, form: User.changeset(params) |> to_form())}
end

Built-in form recovery

When the connection drops, LiveView is smart enough to skip patching the form values in the reconnect re-render to avoid erasing user input. Right after the reconnection, LiveView triggers the phx-change event to send the form parameters to the server, since they were lost when state was discarded.

Note that it requires the form to have an id.

Demo

Try changing both forms and crashing the LiveView to form recovery in action.

Recovery on

Recovery off (without :id)

Reconnects:

The built-in form recovery won’t be enough if the form depends on non-initial state that isn’t naturally persisted in the browser.

Custom form recovery

A good example of state not in HTML is a select input for which the options need to be queried from database and depend on the value of another field.

~H"""
<.form for={@form}>
  <.input type="select" field={@form[:type]} options={["movie", "song"]} />
  <.input type="select" field={@form[:media]} options={@options} />
</.form>
"""

The implementation for this pattern relies on _target key on phx-change event. It contains the form input being changed and allows to query the database for the options only when necessary.

def handle_event("validate", params, socket) do
  case params["_target"] do
    # Update options only when type changes
    ["type"] ->
      # Erase the old options, now invalid
      favorite_params = Map.delete(params["favorite"], "options")

      {:ok, 
        assign(
          socket,
          form: to_form(favorite_params),
          options: load_options_for_type(favorite_params["type"])
        )}

    _ ->
      {:ok, assign(socket, form: to_form(params["favorite"]))}
  end
end

The default reconnection strategy won’t work for this form, since the "validate" event won’t contain the _target. For this situation, LiveView allows customising the event triggered by reconnection:

~H"""
<.form phx-auto-recover="recover" phx-change="validate" for={@form}>
  ...
</.form>
"""

Because of the phx-auto-recover attribute, the reconnection will trigger the "recover" event instead "validate". A specialised event handler can re-create the discarded options assign:

def handle_event("recover", %{"favorite" => params}, socket) do
  {:ok, 
    assign(
      socket,
      form: to_form(params),
      options: load_options_for_type(params["type"])
    )}
end

This is everything you need to know to provide a great user experience with forms during reconnections.

Recovering any other state

Sometimes you have to store UI state in the server which is not related to forms at all. Here’s a concrete example:

Onboarding Demo

Step: First

In the example above, the wizard flow step is controlled by the @step assign. Once LiveView reconnects, @step gets reset to initial state.

One might be tempted to store @step in a HTML form in order to leverage the phx-auto-recover event. It works in the sense that you do recover the state, but since it only runs after the reconnect mount, the user interface will briefly flash the initial state.

What we need is a for the reconnect render to output the correct markup right away. The solution is to recover the lost state in the mount/3 callback.

Recover state during mount with connect params

LiveView sends JavaScript defined params to the server during the websocket connection and reconnections. By default, it is only used to send the csrf_token:

liveSocket.connect("/live", Socket, {
    params: {_csrf_token: csrf_token }
  }
)

The params option also accepts a function that can be used to recover lost state on the initial reconnect render.

You can store any important to recover state in the HTML and query this data to send as a connect param:

liveSocket.connect("/live", Socket, {
  params: () => {
    let step = document.querySelector("[data-step]")?.dataset.step
    return {_csrf_token: csrf_token, step: step}
  }
})

And then read this state in the mount/3 callback with get_connect_param/1 :

def mount(_params, _session, socket) do
  initial_step = get_connect_param(socket)["step"] || 0
  {:ok, assign(socket, step: initial_step)}
end

You can write ExUnit tests for this type of reconnection using put_connect_params/2 test helper:

test "recovers initial step on reconnects", %{conn: conn} do
  {:ok, live, _html} =
    conn
    |> put_connect_params(%{"step" => 3})
    |> live("/page")
  
  assert has_element?(view, "p", "Step Three")
end

Monitoring reconnects

The last thing I’d like to cover is monitoring the amount of reconnects in you application. LiveView emits telemetry metrics which you can use to track things like latency and error rate of lifecycle callbacks like mounts and handle_events.

alias Phoenix.LiveView

def metrics() do
  [
    summary("phoenix.live_view.mount.stop.count",
      unit: {:native, :millisecond},
      tags: [:view, :is_connected, :is_reconnected],
      tag_values: &tag_values/1
    )
  ]
end

def tag_values(metadata) do
  mounts = LiveView.get_connect_params(socket)["_mounts"] || 0

  metadata
  |> Map.put(:is_reconnected, mounts >= 1)
  |> Map.put(:is_connected, LiveView.connected?(metadata.socket))
end

With this setup you can use Grafana or whatever metrics tooling of choice to track the percentage of reconnect mounts by LiveView. This figure will be useful to prioritise investments in reconnection robustness for each LiveView.

Note on storing state in the address bar

Remember that by storing state in the URL you get state recovery for free, in addition to make application state shareable.

You don’t want to use all the techniques mentioned in this article for a filtering UI for example. The filter form should update the URL with the filters encoded which is the bare minimum UX for filters.

Whenever possible, store it in the URL. When you can’t use the techniques from this article.

Closing notes

In summary:

  • Prefer storing state in the URL when possible
  • LiveView forms recover state automatically
  • Use phx-auto-recover for advanced form state recovery
  • Use get_connect_params/1 to recover other kinds of state

WIP


NOTE: LiveView can fallback to HTTP longpolling in case websocket fails too hard. Wether the transport is HTTP or websocket does not matter for the reconnection strategies, therefore I’ll keep referring only to websockets for brevity.

The server state is initialised twice. Once during HTTP request after which it is discarded (remember HTTP protocol is stateless)