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)