TL;DR
To debounce frequent server-side updates: use socket.private for temporary
storage and only update socket.assigns when you want to send updates to the
client.
Background
Back in January, we started having problems with a LiveView application I work on: one of the most important pages of the application began feeling laggy in certain situations. To help set the stage a bit more: this particular page is a dashboard of betting odds from lots of sportsbooks for upcoming sporting events, the events are displayed one league at a time, and the user can select which league they want to look at.
As I said before, this was happening in January, NCAA men’s college basketball (NCAAB) season was in full swing, which means there are lots of events on that odds dashboard page when someone is looking at that league – many more events than if they were looking at a different league.
As I began looking into what was causing the lagginess, I noticed that the LiveView processes on the server seemed to be fine: their message queues were not growing or anything like that. When I looked at the WebSocket messages being received by the browser, I noticed the frequency of messages for NCAAB was much greater than for other leagues. It’s been a while, so I can’t remember the specifics, but I believe I was seeing in the ballpark of 50-100 messages per second. It’s then that I began to suspect the lagginess was due to the client-side of Phoenix LiveView was not keeping up, and most likely the problem was in morphdom.
I’ve known for a while that our LiveView templates need improvement in order to have each update sent to the client be much smaller than they currently are. And, I suspect if I were able to fully optimize our rendering code then perhaps the client-side would be able to keep up with that frequency of updates we were sending. I’ve made some attempts at optimizing our templates, and my experience was that it was a lot of tedious work for minimal gain, what I needed in the moment was a major improvement and needed it quickly.
That’s when I had the idea: what if I could debounce the updates server-side and then send updates to the client at a rate the client-side JavaScript could handle and that still felt lively to our users. I searched online to see if anyone had written about doing this but found nothing – well I found stuff about debouncing client-side, such as waiting some to finish typing, but that’s not what I was interested in. So, I had to try to figure out the solution myself.
Keeping updates private
The first challenge was to figure out how to update the LiveView state without
triggering a rerender which would then cause an update to be sent to the client.
The way the LiveView worked was pretty typical: each LiveView process subscribed
to some PubSub topics, and when the LiveView received a message from one of
those subscriptions it would update its socket.assigns. Any time a LiveView’s
assigns are updated a rerender is triggered, which is what I needed to avoid. I
had noticed previously that the Phoenix.LiveView.Socket struct had
a private field that Phoenix itself uses for some of its functionality, like
asynchronous operations. But, I had never tried using it before and so I
wondered if updating a value in it would cause a rerender: it does not!
Knowing I could update socket.private and not cause a rerender, I then had
the outline of what I needed to do: temporarily store updates in
socket.private and then use that to update socket.assigns on a less frequent
interval. I’m not going to go into the details of how I did that in that odds
dashboard LiveView, instead I’ll give you a very simple example.
Debouncing example
We’ll start with a LiveView that updates a count assign every 10 milliseconds:
defmodule TickLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(10, :tick)
end
{:ok, assign(socket, :count, 0)}
end
def handle_info(:tick, socket) do
{:noreply, update(socket, :count, &(&1 + 1))}
end
def render(assigns), do: ~H"<span>{@count}</span>"
end
That’s pretty routine LiveView/GenServer kind of stuff: every time a :tick
message is received the socket.assigns.count value is incremented. LiveView
will take that change to its assigns, rerender the template, and send an update
to the client.
Now let’s say we only want to render the change to our count every 200 milliseconds, instead of with every tick:
defmodule DebounceLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(10, :tick)
:timer.send_interval(200, :flush)
end
{:ok, socket |> assign(:count, 0) |> put_private(:count, 0)}
end
def handle_info(:tick, socket) do
{:noreply, update_in(socket.private.count, &(&1 + 1))}
end
def handle_info(:flush, socket) do
{:noreply, assign(socket, :count, socket.private.count)}
end
def render(assigns), do: ~H"<span>{@count}</span>"
end
As you can see when a :tick message is received in the second code snippet,
instead of updating socket.assigns.count as was done before it now updates
socket.private.count. When a :flush message is received
socket.assigns.count is updated using the value from socket.private.count,
and it’s only at this point that LiveView will rerender the template and update
the client.
If you want a fully functional Phoenix LiveView application that you can drop into an IEx session or into a Livebook to see the debouncing in action:
Mix.install([{:phoenix_playground, "~> 0.1.8"}])
defmodule DebounceLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
if connected?(socket) do
:timer.send_interval(10, :tick)
:timer.send_interval(200, :flush)
end
{:ok, socket |> assign(:count, 0) |> put_private(:count, 0)}
end
def handle_info(:tick, socket) do
{:noreply, update_in(socket.private.count, &(&1 + 1))}
end
def handle_info(:flush, socket) do
{:noreply, assign(socket, :count, socket.private.count)}
end
def render(assigns), do: ~H"<span>{@count}</span>"
end
PhoenixPlayground.start(live: DebounceLive, port: 8000)