Bigger than assumed network footprint for LiveView

I build a dash board for an imaginary stock portfolio (Phoenix 1.6). I assumed that LiveView only sends the data which changes but it seems that a lot more is send. Here’s a screenshot:

Am I using LiveView in a wrong way? Shouldn’t the network footprint for updates be much smaller? I know that we are still talking peanuts here. I just want to understand why all @stocks are always updated even when just one element of @stocks has changed and how a possible fix would look like.

Here’s the code:

defmodule DemoWeb.StockWatchLive do
  use DemoWeb, :live_view

  @refresh_rate 2500

  def mount(_params, _session, socket) when is_map_key(socket, :stocks) do
    {:ok, update_world(socket)}
  end

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, @refresh_rate)

    {:ok, initialize_world(socket)}
  end

  def render(assigns) do
    ~H"""
    <table>
      <tbody>
        <tr><td>Barguthaben:</td><td><%= @balance %></td></tr>
        <tr><td>Wert des Portfolios:</td><td><%= @portfolio_value %></td></tr>
        <tr><td>Summe:</td><td><%= @balance + @portfolio_value %></td></tr>
      </tbody>
    </table>
    <table>
      <thead>
        <tr>
          <th>Name</th>
          <th>Preis</th>
          <th>Portfolio</th>
          <th colspan="2"></th>
        </tr>
      </thead>
      <tbody>
      <%= for {stock_name, value} <- @stocks do %>
        <tr>
          <td><%= stock_name %></td>
          <td><%= value %></td>
          <td><%= Map.get(@portfolio, stock_name) %></td>
          <td width="20%">
            <%= if value <= @balance do %>
              <button phx-click="buy" phx-value-ref={stock_name}>Kaufen!</button>
            <% end %>
          </td>
          <td width="20%">
            <%= if Map.has_key?(@portfolio, stock_name) && Map.get(@portfolio, stock_name) > 0 do %>
              <button phx-click="sell" phx-value-ref={stock_name}>Verkaufen!</button>
            <% end %>
          </td>
        </tr>
      <% end %>
      </tbody>
    </table>
    """
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, @refresh_rate)

    {:noreply, update_world(socket)}
  end

  def handle_event("buy", %{"ref" => stock_name}, socket) do
    {:noreply, execute_order(stock_name, 1, socket)}
  end

  def handle_event("sell", %{"ref" => stock_name}, socket) do
    {:noreply, execute_order(stock_name, -1, socket)}
  end

  defp stock_price(stocks, stock_name) do
    Map.get(stocks, stock_name)
  end

  defp portfolio_value(portfolio, stocks) do
    for {stock_name, amount} <- portfolio, reduce: 0 do
      running_sum -> running_sum + stock_price(stocks, stock_name) * amount
    end
  end

  defp execute_order(stock_name, amount, socket) do
    %{:stocks => stocks, :portfolio => portfolio, :balance => balance} = socket.assigns

    portfolio = Map.update(portfolio, stock_name, 1, &(&1 + amount))

    balance = balance + -1 * amount * stock_price(stocks, stock_name)

    socket
    |> assign(portfolio: portfolio)
    |> assign(portfolio_value: portfolio_value(portfolio, stocks))
    |> assign(balance: balance)
  end

  defp update_world(socket) do
    %{:stocks => stocks, :portfolio => portfolio} = socket.assigns

    stocks =
      for {k, v} <-
            stocks,
          into: %{},
          do: {k, random_fluctuation(v)}

    socket
    |> assign(stocks: stocks)
    |> assign(portfolio_value: portfolio_value(portfolio, stocks))
  end

  defp random_fluctuation(value) do
    case Enum.random(1..4) do
      4 -> value + Enum.random(-5..6)
      _ -> value
    end
  end

  defp initialize_world(socket) do
    socket
    |> assign(
      stocks: %{
        "Aktie A" => 100,
        "Aktie B" => 200,
        "Aktie C" => 300,
        "Aktie D" => 400
      }
    )
    |> assign(portfolio: %{})
    |> assign(balance: 1_000)
    |> assign(portfolio_value: 0)
  end
end

On my mobile so hard to see the whole code, but afaik you should not use functions in the render. Those should be done in the assigning function so the diff tracking can do it’s job. See liveview docs about the diffing.

It’s OK to use functions in the render, and even encouraged in the documentation:

# Avoid:
<% some_var = @x + @y %>
<%= some_var %>
# Instead, use a function:
<%= sum(@x, @y) %>

The Pitfalls referred to in the docs are:

  • Non-standard do/end blocks
  • Variable assignment

Neither seem to apply in this case.

Looking at the logs, it doesn’t seem like an abnormally large footprint, though. The assign which is being updated is @stocks - the list of stocks. So it makes sense that each time you update the list, the full list is sent. To avoid this, maybe temporary assigns could be implemented?

I don’t think you have anything to worry about yet. Something you may want to avoid is to have a large complicated data structure, which is almost always changing, in the assign, and to have an accessor function that take a small slice of it, which often don’t change, and use it in the rendering.

The for comprehension to render the table rows is going to re-render and resend all the stock rows when any individual stock(s) change, so that is what you are seeing. You can optimize this case by rendering the table rows as a LiveComponent, which will only produce a diff for the changed stocks.

14 Likes

Thank you! That was the missing piece I was searching for.

3 Likes