LiveView performance problems - Morphdom taking up to 1500ms to patch updates

Great point, and you’re right that the core components has a lot of dynamic parts when it comes to classes, that is mostly due to the use of TailwindCSS, you can match your CSS on pretty much anything Attribute selectors - CSS: Cascading Style Sheets | MDN

Tailwind is great as a learning resource and small projects, but you would be hard pressed by avoiding to learn CSS it is an essential part in understanding how the browser works.

I’ve been doing CSS since back in the days when it was paramount to debug selector performance, just using bare element tags were considered slow. I would say I’m one of the few people on the planet who actually like CSS :smiley:

But yes, Tailwind might not actually be that great a fit for LiveView, I do like the tooling though, but you can’t have it all.

Would it help if you write instead:

  def fa_icon(assigns) do
    App.Components.SVGIconLibrary.icon("fontawesome", assigns.category, assigns.name, class: assigns.class)
  end

Awesome, selector performance have become less of an issue today, your biggest problem will come from layout shift and repainting, and then we come back to thinking about how you construct your HTML and CSS.

classes are still superior, but you want to mix in a little bit of attribute selectors for anything dynamic.

A component will not even be invoked in the first place if the assigns you pass to it have not changed. So its assigns either changed or you are inside a comprehension.

However, I would say rendering a SVG like that is an anti-pattern. You should embed it like Phoenix deals with hero-icons or pick another strategy. Otherwise, even if you cache or make it work with change tracking, if you need to render it in 10 different places, it will still be sent in 10 different places (regardless if you are using LiveView or any other web framework).

1 Like

The way heroicons does it in Phoenix is pretty terrible imo, bloating the css with a lot of unused binary data (if I recall correctly, it was a long time ago I stopped using them), I want to be able to do css fills and group/path animations :slight_smile:
Also the icon is mostly irrelevant, nobody except me does it like that, the other stuff is a lot more common. It was more to show the pitfall of components and how they get resent even when nothing has changed.

But yes, it’s in a comprehension, but what isn’t, so much of everything we do that has to do with rendering is render more than one item of a kind. In React you’re expected to help keep track using the special ”key” prop and if you don’t, well it’s not great :sweat_smile:

From your problem I think it came from LiveView process (server side) than Morphdom (client). From your question & your info I think too much dynamic content for LiveView process calculate diff HTML to push to client.
I have solved a quite similar case with stock list (It can run to 10_000 items - Each item has about 5-7 DOM need to update, almost browser can handle is 5_000 - 7_000 items, Firefox is still run more but Chrome is not!).
For an other case, I need to add a list static Items (then using pagination, map content to HTML element & hide unused items) because each HTML element has a lot of data need to render that take time for LiveView process render if I use dynamic generate list.

For your case I think better you use stream + LiveComponent + cache data changed before assign to LiveView’s socket.

From my experience I think need to find a way to optimize dynamic content for kind of render HTML from enumerable type like list/map.

I have small guide to share with you I hope it can help you
LiveView optimization guide and video I shared about stock list.

When I have the time, I plan on writing a reproducible example, but just for reference, I’ve just written a prototype in VueJS using GitHub - vuejs/petite-vue: 6kb subset of Vue optimized for progressive enhancement. I haven’t implemented client-side validations yet, which certainly will slow down things a little (but the elixir validation was usually below 1ms), but I’m getting very good performance with 600 nested inputs when I edit a form field or create a new nested input. The following is from Chrome’s performance indicators:

I know that without seeng thea actualy code it doesn’t mean much, but so far I’m very happy, especially because petite-vue uses the actual rendered DOM as a template at runtime, and it does so rather efficiently, which allows me to avoid a JS build step if I want to. So it does not complicated my assets pipeline more than it needs to.

An example of the form built with VueJS: https://codepen.io/Tiago-Barroso-the-builder/full/gbOarMY

You have a table, which contains a number of columns. Columns can have one of several types, and to avoid problems with data invalidation if the user changes the type of a column, once chosen, the type of the column is fixed and can’t be changed. This is reflected in the UI. You can delete a column and replace it by another with the same name and a differnt type, though. If you create a column of type choice, you will get the ability of creating choices for that column. This will add to the form a list of choices, for which you can input the name and code. In the same column, choice names and codes should be unique, but this isn’t checked yet. I have not yet implemented moving choices up or down in the list yet. Column names should be unique, and this is indeed checked (and feedback is given to the user in case of validation errors).

The following static webpage creates in Javascript the following data, which is then displayed by the very minimalist VueJS component (build using Bootstrap classes):

  • 1 table
  • 600 columns, to which you can add more columns (above or below the current column) and which can be reordered using the Move up and Move down buttons. I could have added a drag and drop interface (there are many such interfaces compatible with VueJS), but I think that it would be very confusing and the current approach of moving columns with buttons is more suitable for big and complex forms
  • One out of each 10 columns (column nr 6, 16, 26, etc.) will have type choice and will have been populated with 60 choices. I don’t expect more than that number of choices for real use
  • 3 out of 3 columns will be populated without a type so that you can play with selecting the column type

The performance metrics are quite good. The initial page rendering taks aboute 2.5s, which is long but acceptable as a one-time cost. The content paint time (the time from any interaction to the DOM refresh) isn < 50ms. It’s totally imperceptible and it doesn’t nead any client-server communication.

The main things I lose here are internationalization (I’ll probably have to serialize the messages into some kind of JSNO object and pick it up by javascript so that I can have proper internationalization) and the “built-in” validation of Ecto changesets - It tried to make my Javascript state map clolsely to Ecto changesets, but I couldn’t make it work in a way that was eficient. When I send the JS state to the server to persist the table I’ll have to strip it of UI concerns and ,re-validate it with changesets.

Field validation is ad hoc: when possible, I validate the field directly inside the form so that everything is as local as possible. To validate unique constraints, I actually intercept the input change and then set the errors of that field and of other fields imperatively in a way that’s captured by VueJS. This is probably a bit more complex than it should be, but it works well and is efficient.

I don’t think this is possible with LiveView’s current approach, but it’s probably the upper bound of what one can get without manipulating the DOM directly at a very low level. Maybe @josevalim could weigh in on whether LiveView’s approach could ever get somehwat close to these numbers.

It makes me think that an interesting approach would be to have shared state between VueJS and a Phoenix channel so that the channel could receive events from the web browser and then sen events back. Sharing a state could be done by forcing the state to be a map of JSON-serializable objects. Mutations could be sent as JSON patches back and forth to minimize data transfer costs, or directly using JS operations, such as Array.splice(), which according to the VueJS docs lead to very efficient array mutations. The big question is whether it would be worth it to abstract this somehow or whether it’s better to build an ad hoc component like I deed.

I can see how a customized channel implementation with shared state in the form json patches could help client-to-server communication with minimal data transfer could lead to something very similar to LiveView with better performance characteristics. The only question is whether one should try to compile HEEx components to VueJS or just bite the bulet and write components using the VueJS template language, which is quite simple. I’d have to think of an interface for stateful input validation, as Changesets don’t seem very ergonomic for that purpose.

1 Like

You can take a look at LiveState: live_state v0.8.2 — Documentation - but double check if it does perform patches for you.

After seeing your description (1 table, 600 columns, one of 10 with 60 choices), that in itself is at least 3600 + 540 entries! Tracking ~4k values at once will indeed require large payloads unless you fine-tune it. I would still argue for a workflow where you work with columns individually. I’d have to look at a repro to see how much we could optimize in LiveView though.

3 Likes

That monster form is just an extreme stress test, not something I actually expect users to actually build.

Sure, but again, don’t take this as a real workload.~~

Because the nested inputs are collapsible, you can collapse everything you’re not working with and ignore it. IMO it’s a good compromise and I like it a lot for my purposes.

Actually, I don’t think the current LiveView way of working is compatible with large forms however much you optimize it. The current paradigm assumes a form will be submitted (the message even uses URL encoding for the form values, like it would be in a POST request, which is very wasteful in terms of space) on any value change (possibly with throttling, of course). While one can (and should!) throttle the input so that it’s not sent everey keypress, things become more complicated when you’re adding or deleting new inputs to a nested form. The way the liveview guides suggest it should work is by using checkboxes disguised as buttons (and that’s how I implemented it).

One easy optimization would be to discourage that approach and encourage sending custom events which cause the application to create, delete or reorder the nested inputs using (possibly nested streams). I confess I didn’t look into this approach, as if I’m willing to do imperative manipulation on the DOM (which is basically what streams do) and to forego the simplicity of doing %Schema{} >>> %Changeset{errors: ...}, I guess I’d rather take things client-side.

I wonder if LiveView should store something like a tuple {original_schema, last_changeset, attrs = %{}} where you keep the constant schema and add the mutations to the attrs using JSON patches (or something like that) and upon input validation you build a changeset with new_changeset = Repo.change_schema(original_schema, attrs) and then create a changeset_diff (the diff could contain errors in parts of the form far away from the ones ) which you send down the wire so that the form knows how to update itself, including updating the errors.

Or maybe a completely new concept is needed to replace changesets, IDK.

It seems interesting, especially with something like this: GitHub - launchscout/live-templates: The live-template element connects a template to a state provider. I think I could implement a VueJS “frontend” inspired by something like that. It doesn’t do JSON diffs, though. It just sends the whole state. But again, that’s something that could be modified.

Regarding the JSON diffs: if we’re only talking about client-server communication (that is, 2 player communication) and most events go from client to server and back and the order of events is guaranteed, I think that something like JSON Patch (which has an Elixir implementation: Jsonpatch — Jsonpatch v2.2.1, and probably 10 million JS implementations) would be enough, and one wouldn’t need to worry about distributed system problems. The only problem with that is whether it would play nice with the way VueJS expects the state to be mutated: especially for arrays/lists, VueJS expects arrays to be mutated with specific functions, like Array.splice().

Another way would be to create a %ClientState{} object which can only be mutated with an interface that can compile to those JS functions. Something like this, in case we want all state change to be driven by the client:

def handle_action("my_event", very_minimal_payload, old_state, socket) do
  # Client state which we keep in memory
  old_state = %SharedState{
    client_state:
      "root" => {
        "name" => "Current Root Name"
        "children" => [
          %{"name" => name1, "description" => descr1},
          %{"name" => name2, "description" => descr2},
          %{"name" => name3, "description" => descr3},
          %{"name" => name4, "description" => descr4},
        ]
      },
    # Optional state you might want to persist in the server
    server_state: %{}
    # List of operations, applied in order, to send down the wire.
    # This is reset each time the event is handled.
    delta: [],
    # List of actions to support undo/redo

  }

  new_state =
    old_state
    # Change the value in a map; efficient for VueJS mutation tracker
    |> SharedState.put(["root", "name"], "New Root Name")
    |> SharedState.delete(["root", "children"], 0)
    |> SharedState.insert(["root", "children"], 1, = %{"name" => n, "description" => d})
    # Move the item towards the beginning of the list
    |> SharedState.swap_with_previous(["root", "children"], _item_index = 1)
    # Move the item towards the beginning of the list
    |> SharedState.swap_with_next(["root", "children"], _item_index = 4)
    # Fancy functionality to support undo/redo; probably should be optional
    |> SharedState.commit("my_event")

  # Return the new client state.
  # If we want to be able to send events from the server, then the state should
  # live in a process or in an ETS table (gated by a process to ensure linearized edits).
  # In that case, the `SharedState.commit/1` would be required, or even something
  # more complex with transactions to guarantee that concurrent edits have a stable order.
  {:ok, new_state, socket}
end

Or if we want both the server and the client to edit the same state, with fancy transactions that lock the state:

def handle_action("my_event", very_minimal_payload, socket) do
  dead_state =
    # Locks the state for the duration of this specific Channel/LiveView/whatever
    SharedState.transaction(state_id, [action: "action_name_for_undo"], fn old_state ->
      old_state
      # Change the value in a map; efficient for VueJS mutation tracker
      |> SharedState.put(["root", "name"], "New Root Name")
      |> SharedState.delete(["root", "children"], 0)
      |> SharedState.insert(["root", "children"], 1, = %{"name" => n, "description" => d})
      # Move the item towards the beginning of the list
      |> SharedState.swap_with_previous(["root", "children"], _item_index = 1)
      # Move the item towards the beginning of the list
      |> SharedState.swap_with_next(["root", "children"], _item_index = 4)
    end)

  # Send the deltas to the client
  SharedState.send_deltas(socket, dead_state)

  {:ok, socket}
end

Reminds me of this discussion where LV forms came up more than once, posting in hope that it adds some value here:)

A super minor note as I only found out about this a few weeks ago (and this is more for future readers), but this hasn’t been the advice in the docs since 0.20.4 Phoenix.Component — Phoenix LiveView v0.20.4

Instead, you can just use a button

# NEW
  <button
    type="button"
    name="mailing_list[emails_drop][]"
    value={ef.index}
    phx-click={JS.dispatch("change")}
  >
    <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
  </button>

vs

# OLD
  <label>
    <input type="checkbox" name="mailing_list[emails_drop][]" value={ef.index} class="hidden" />
    <.icon name="hero-x-mark" class="w-6 h-6 relative top-2" />
  </label>

Also despite this, I think it would still be interesting to see a mockup of your form in a repo, even it it’s just to showcase that having forms of that complexity is just not a good fit for LiveView :slight_smile:

1 Like

You can see the JS (VueJS) version of the form in the link above.

1 Like

I want to look into this a little bit more when I find the time. I’d appreciate it a lot if you could publish the LiveView version somewhere, as that would save me a lot of time reproducing :smiley:

8 Likes

Coming back to this I had a look in Wireshark for my specific payloads in forms, and the compression of most messages were pretty darn insane, which makes sense in a form because almost all the data is identical.
I knew compression helped a lot, but hadn’t looked at this specific type of data before, dumb of me I know, always measure!

1 Like