Slow DOM inserts on larger pages

In my app, I have a transactions list backed by a stream of 100-500 elements. I’ve noticed that when I trigger an action that causes a new DOM element to be inserted above the transactions list in the DOM, this can be very slow. However, inserting below the list of transactions is fast. This has nothing to do with the server or what’s sent over the wire as I’m not updating the stream at all and the diffs are quite small.

From my reading, I’ve gathered that this might be a result of:

  1. Inserting a DOM element causes reflow/layout calculations for elements that follow.
  2. Morphdom may have to do additional book-keeping when inserting elements earlier in the page (unsure about this one).

Interested to hear if others have encountered such issues and whether this is LV + Morphdom related or not.

I’ve attached a minimal repro that streams 10k elements to the page and then allows toggling a header and footer. Toggling the header is slow while toggling the footer is fast. I’m using 10k elements to demonstrate the perf differences and to make up for the fact that the DOM is much simpler in this example than it is for my app.

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7"},
  # please test your issue using the latest version of LV from GitHub!
  {:phoenix_live_view,
   github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
])

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    items =
      Stream.repeatedly(fn ->
        %{
          id: "item-#{System.unique_integer()}",
          date: Enum.random(Date.range(~D[0000-01-01], Date.utc_today())),
          account: Enum.random(["Chase", "Bank of America", "Citi Bank"]),
          amount: Enum.random(0..1000),
          merchant: Enum.random(["Amazon", "Google", "Facebook"])
        }
      end)
      |> Enum.take(10000)

    {:ok, socket |> assign(show_header: false, show_footer: false) |> stream(:items, items)}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js">
    </script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js">
    </script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
      .row { padding: 2px; }
    </style>
    {@inner_content}
    """
  end

  def render(assigns) do
    ~H"""
    <button phx-click="toggle_header">toggle header</button>
    <button phx-click="toggle_footer">toggle footer</button>
    <div :if={@show_header}>header</div>
    <hr />
    <div id="items" phx-update="stream">
      <div id={dom_id} :for={{dom_id, item} <- @streams.items} class="row">
        <div>Date: {item.date}</div>
        <div>Account: {item.account}</div>
        <div>Merchant: {item.merchant}</div>
        <div>Amount: {item.amount}</div>
        <hr />
      </div>
    </div>
    <div :if={@show_footer}>footer</div>
    """
  end

  def handle_event("toggle_header", _params, socket) do
    {:noreply, assign(socket, show_header: not socket.assigns.show_header)}
  end

  def handle_event("toggle_footer", _params, socket) do
    {:noreply, assign(socket, show_footer: not socket.assigns.show_footer)}
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)

  plug(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
  plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")

  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)
1 Like

You can try with content-visibility - CSS: Cascading Style Sheets | MDN but most of the time you want to insert new elements at the top, think about your list as a timeline, if that is not possible, you need to optimize your CSS. Good luck, writing CSS can be daunting, but is rewarding and is an essential part of web development.

1 Like

but most of the time you want to insert new elements at the top

Do you mean “most of the time you want to insert new elements at the bottom”? Since that would avoid the perf issues.

Also thank you for the note about content-visibility! I agree that could address the perf issues, but I don’t like the downsides this would incur (e.g. not being able to search for text of hidden rows).

In my case, I don’t actually need to insert at the top and can work around potential issues, but I was surprised by the magnitude of the difference. Made me interested in learning more about whether this is a generally known limitation of the DOM or if Morphdom is exacerbating the issue at all. I do plan on try to repro this with other approaches (e.g. manual DOM manipulation in JS) and will report back on the results.

1 Like