Multi-second rendering time when _adding_ elements to LV page

Here is a dummy LV that replicates some strange behavior I’m seeing in my production application. As you can see, the app renders a mildly complicated HTML paragraph for every number from 0 to 1,000. It then allows you to filter that list to numbers evenly divisible by a user-entered value. That’s it.

defmodule ReadyroomWeb.Speriment do
  use Phoenix.LiveView

  def render(assigns) do
    use Phoenix.HTML

    ~L"""
      <form id="filter-form" phx-change="filter">
        <%= text_input(:filter, :value, value: @filter_value, class: "form-control", placeholder: "filter by", phx_debounce: "100") %>
      </form>

    <%= for i <- @renderable do %>
      <p>The number <b><%= i %></b> is <i>evenly</i> divisible by the <strong>number</strong> <code><%= @filter_value %></code>.<p>Here's a nested paragraph</p><div>This is also nested</div><p></p></p>
    <% end %>
    """
  end

  def mount(_params, _session, socket) do
    data = 0..1000
    {:ok, assign(socket, filter_value: 1, data: data, renderable: data)}
  end

  def handle_event("filter", %{"filter" => %{"value" => ""}}, socket) do
    handle_event("filter", %{"filter" => %{"value" => "1"}}, socket)
  end

  def handle_event("filter", %{"filter" => %{"value" => filter}}, socket) do
    renderable = Enum.filter(socket.assigns.data, &(rem(&1, String.to_integer(filter)) == 0))
    {:noreply, assign(socket, renderable: renderable, filter_value: filter)}
  end
end

If you run this, it initially renders very quickly. As you type filters, e.g., 10, 100, 1000, 10000, it continues to render quickly. But as you back over the digits in the filter, making it more and more inclusive, (1000, 100, 10, 1) it renders slower and slower. If you simply delete the filter, changing it from 10000 to 1 or blank, it takes (on my machine) nearly seven seconds to render.

Even more unusual, it slows down appreciably every time I stick a junk tag in the output. That’s what those noisy HTML tags are all about. If, for instance, I add an empty div tag to the beginning of the paragraph, the 7 seconds increases to 22 seconds!

I suspect I am missing something fundamental above LV and Morphdom. Any ideas?

Not sure if it is important but you cannot nest <p> tags, put divs inside p tags etc.

While I don’t have

tags in production, changing them for this example made a marked improvement. While it’s still slow-ish when the filter is deleted, it’s no longer pathologically slow. That is, 7 seconds has fallen to 1. Maybe, I have violated another HTML constraint and that’s why I’m seeing 30 second redraw times in prod. I’ll let everyone know if I find anything. And thanks!

Is it server side or client side delay? If the server sends a diff within milliseconds, morphdom
/js is having issues. If the server responds slow, we can start looking at LiveView/the elixir part

Sorry, I meant to mention that. This is purely client side. Using the dev tools I can see the response come back nearly immediately, then the thread visibly freezes while it updates the DOM.

While the above code does reproduce the problem, keep in mind that making the HTML syntactically correct improves things dramatically. I reviewed my actual code, and don’t see any obvious HTML syntax problems. I will try and fine tune the example and see if I can get it to go pathological even with correct syntax.

Your for comprehension is going to render 1000 rows on the server, then send that over the wire, to re-render all 1000’s of DOM elements. The delay you see is probably 99% client side, but in general this is not the way to go about rendering such a large collection. Look into phx-update="append" for handling this kind of operation efficiently :slight_smile:

1 Like

To add: Browsers in general do not like thousands of elements. If your lists’ root element has 10 children you are looking at 10.000 elements. Any browser will lag when you feed it such numbers without static positions and fixed sizes.

As relative position forces the browser to first compute the height of list element 1 (say 20px), then place the second element below (start 20px) and compute it’s height (again 20px) go on to the third and place it at 20+20=40 px and so on and on. Now this is an example with fixed height of 20px. Imagine if it is variable and it needs to include the calculations of child elements and you start to see the number of calculations which must be performed.

Browsers are highly optimized for this task but only when you match the specs. Once you nests a Paragraph in a Paragraph that optimization breaks and will cause even more render delay.

The solutions? None fits all but here are some candidates:
1.) ensure you keep the amount of elements to the minimum
2.) ensure only those within the visual space are rendered. This can be done with pagination and/or virtual lists. (Like in React with react-virtualized)
3. Use fixed sizes for elements
4. Use static positioning

I appreciate the feedback, and I will adjust my approach accordingly. However, the production issue that prompted this does not actually have 1,000 “entities,” it has just 200. To paint a picture, it’s a filterable list of requests for documents. And while I’ve used phx-update="append" for the simple chat interface that’s also in the product, I’m not sure how I would use it for a filter, that is, for a list the shrinks and grows.

Now, each “request” is made up of a number of elements, divs, and checkboxes, and whatnot, but not excessively so. In fact, the page initially renders very quickly, which leads me to believe the browser can handle it reasonably well. Even as the user types in the filter field and the list shrinks, it continues to render quickly.

It’s when the filter is cleared that the page becomes unresponsive for 20-60 seconds as it is rebuilt. It’s that excessive delay, combined with the quick initial render, that leads me to suspect morphdom/liveview and not page complexity or browser rendering challenges.

That said, I’m more than prepared to hear that I’m doing something stupid or complex, I just don’t know what it is right now. Filtering a list of 200 entities, should not be that taxing.

Can you check your browser console to see how much data it is receiving from the web socket? Are the number of messages and the size reasonable close to what you were assuming?

It still should not b that bad. I sent a very large nested list of 650 items that total about 500KB, my browser struggled for maybe 1~2 seconds. In both Firefox and Safari. On a phone, it only feels slightly slower.

Okay, I have deployed an accessible version of this issue here: - Ready Room

This version reasonably faithfully replicates my production issue in that the (dummy) HTML is structured the same as in prod and there are 200 rows (which is not an unreasonable page size). As you can see, it renders quickly at first.

There’s a filter field on top with a 500ms debounce setting. If you type a number in the field, it will filter the list down to just those rows that have an “ID” evenly divisible by that number, as before. Slowly typing 1 then 12 then 123 then 1234 will work as you expect. However, if you back over the filter (123, 12, 1), it will render progressively slowly. But simply deleting the filter is the most dramatic. In that case the 23K response returns in approximately 500ms, but the page doesn’t finish rendering until some 20-30 seconds later!

While I do appreciate the work browsers go through to render a page, in this case it seems clear that the issue is not due to page size or complexity, but it’s somewhere in LV/morphdom.

I do have a workaround, however. If I wrap that list in an element with an ID tied to the filter assign, then LV/morphdom doesn’t merge each node, but just chunks in a whole new list, which renders quickly. IOW, if I change the wrapping div to something like the following, it all becomes snappy again: <div id="released-tasks-<%= @filter_value %>"

Hopefully, this will help people in tracking things down.

Can we see the code for it?

Do you put IDs on the rows?

Absolutely. I’ve pasted the code below. It’s pretty much the same as what I initially posted, but I changed the HTML to more closely reflect my production code, just in case that mattered.

I do not have IDs on each row. However, I just tried that locally and it made no difference.

It may be useful to know that if you watch the DOM being built in the “Elements” view of your dev tools, that you can see the outer div of each row (<div class="released-task complete">) being created, then there’s the 20ish second pause, then the internals of each row appear.

defmodule ReadyroomWeb.Speriment do
  use Phoenix.LiveView

  def render(assigns) do
    use Phoenix.HTML
    ~L"""
    <div class="container mb-3" style="max-width: 90%;">
      <form id="filter-form" phx-change="filter">
        <%= text_input(:filter, :value, value: @filter_value, class: "form-control my-5", placeholder: "filter by", phx_debounce: "1000") %>
      </form>

      <div id="released-tasks" class="released-tasks download-hook" phx-hook="InspectorView">
        <%= for task <- @renderable do %>
          <div class="released-task complete">

            <div class="released-meta">
              <div class="released-meta-grid">
                <i class="fas fa-chevron-right"></i><div><%= task %></div>
                <i class="fas fa-user"></i><div data-toggle="tooltip" title="Requested by"><%= "Some User" %></div>
                <i class="fas fa-clock"></i><div class='local-time' data-toggle="tooltip" title="Requested at"><%= NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) %></div>
                <i class="far fa-clock"></i><div class='local-time' data-toggle="tooltip" title="Fulfilled at"><%= NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) %></div>
                <i class="fas fa-check"></i>
                <span>Complete</span>
              </div>
            </div>

            <div class="released-content">
              <div class="released-title"><%= "This is the request: #{task}" %></div>
              <div class="released-response">
              <strong class="mr-2 text-info">Response:</strong><span><%= "This is the response: #{task}" %></span>
              </div>
            </div>
            <div class="released-files uploads">

              <table class="table table-borderless table-sm table-hover uploads">
                <tbody>
                    <tr>
                      <td> Filename </td>
                      <td> File Length </td>

                      <td class="nowrap">
                        <a href="#" >
                          <i class="fas fa-long-arrow-alt-down"></i>&nbsp;A non-working download link
                        </a>
                      </td>

                      <td align="right">
                        <span class="file-size nowrap">File Size</span>
                      </td>
                    </tr>
                </tbody>
              </table>

            </div>
          </div>
        <% end %>
      </div>
    </div>
    """
  end

  def mount(_params, _session, socket) do
    data = 0..200
    {:ok, assign(socket, filter_value: 1, data: data, renderable: data)}
  end

  def handle_event("filter", %{"filter" => %{"value" => ""}}, socket) do
    handle_event("filter", %{"filter" => %{"value" => "1"}}, socket)
  end

  def handle_event("filter", %{"filter" => %{"value" => filter}}, socket) do
    renderable = Enum.filter(socket.assigns.data, &(rem(&1, String.to_integer(filter)) == 0))
    {:noreply, assign(socket, renderable: renderable, filter_value: filter)}
  end
end

It is triggering some crazy GC loop in the javascript. Can you remove all unnecessary js, css, bootstrap or others, and try again.

I owe you a beer, @derek-zhou. Removing all unnecessary JS and CSS caused the problem to go away. Putting it back slowly showed that the problem is with, of all things, Font Awesome. Add Font Awesome in, problem appears. Remove it, problem disappears. Add it back but remove all font awesome fonts, and the problem remains at bay.

I’m glad this problem is not in LiveView/Morphdom, and my apologies for coming here before exhausting all other possibilities. Thank you everyone for your help and insight.

But now I have some work to do. :slight_smile:

1 Like

Follow up for anyone else who may hit this problem, it’s the “Font Awesome with JavaScript” (SVG) implementation (all.js) that has the problem. Switching to the web font version (all.css) also eliminates the issue. Note, this may not be a viable workaround for everyone.

3 Likes