Jskalc
Hi everyone! Recently I was thinking a lot about the way HEEX renders lists. People are generally surprised about huge payloads being send on any list change, and I had to work around this in one of my recent projects to keep site responsive.
I think there’s a possible payload optimisation inspired by client-side VDOM implementations. Let me explain by providing a very simple phoenix playground rendering a list.
Mix.install([{:phoenix_playground, "~> 0.1.3"}])
defmodule DemoLive do
use Phoenix.LiveView
def render(assigns) do
~H"""
<button phx-click="add">add</button>
<ul>
<li :for={i <- @items}>
<%= i.name %>
</li>
</ul>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :items, [])}
end
def handle_event("add", _params, socket) do
items = socket.assigns.items
id = length(items)
new_item = %{id: id + 1, name: "New#{id + 1}"}
{:noreply, assign(socket, :items, [new_item | items])}
end
end
PhoenixPlayground.start(live: DemoLive)
The problem
LiveView has many exciting optimizations, but they mostly doesn’t apply if you’re using a “:for” loop. In my basic example, on each list update LiveView sends all the elements again, even if most of them are exactly the same as before.
In this case, problem could be solved trivially by using Streams. But in many other cases it’s not feasible because:
- Streams works only as top-level assigns. If you have list as an attribute of an object (eg. ecto associations like
current_user.posts) then using streams is not easy. - Streams API is different. You need to understand how exactly list changes. It might complicate your business layer (contexts) just to get that information.
- Streams gives you both memory optimization and payload optimization. I believe second one should be available out of the box, even without using streams.
Idea
This problem is not new. Multiple frontend frameworks use Virtual DOM, where they need to calculate a minimal patch between VDOM and DOM. They simply require :key attribute when rendering a list, for example Vue.js
Then, it’s rather trivial to figure out:
- if element is new (key not present in the old state, but present in the new one)
- if element was removed (key present in the old state, but no longer in the new one)
- if element was updated (key present both at old and new state, then we should calculate diff recursively for these elements)
So, maybe LiveView could go the same route? If we could introduce :key as an optional attribute, HEEX engine could calculate:
- added elements (and their positions)
- removed elements
- updated elements (and their positions)
and send an efficient, minimal payload to the client. This actually could even enable efficient diffs for components nested below :for loops.
An example (notice added :key):
def render(assigns) do
~H"""
<ul>
<li :for={i <- @items} :key={i.id}>
<%= i.name %>
</li>
</ul>
"""
end
Implications
- This would require to keep previous assign of a list around in
assigns.__changed__, which is currently not the case. - This would impose a small overhead of calculating old keys, new keys and figuring what was updated, but only when
:keyis present. I think it’s worth to do it and send a smaller payload than to send everything. - Reordering might require some thoughts
- I’m not sure if it would be a breaking change, possibly not?
All in all, I believe that optimization should be possible to accomplish. I might give it a shot myself, just wanted to first ask community for some feedback
What’s your opinion?
EDIT: I wasn’t aware it’s a category only for registered users, could someone move it to phoenix questions or some other public place?
Would like to refer this in github PR / issue if I’ll be able to sit down and tackle it.
Trending in Proposals: Ideas
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #blog-post
- #phoenix_html
- #iex
- #graphql
- #ai
- #genstage
- #elixirconf-us
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex











Showing Posts 1 to 10- Show Best Posts
- Show All Posts (oldest first)
- Show All Posts (newest first)
josevalim
There are two challenges in making this practical:
How will your template know that the right side of ← in your comprehensions is state it needs to keep around between renders in order to generate diffs? It is easy to do at the top level (which is what streams do) but hard otherwise. Even if you keep lists in
__changed__, how will you diff, for example, a list inside another list? Or a list that is generated by a function or Enum.map?If the list is large, you will need to traverse the old and new list in order to find which keys were added and removed. And you have to keep the whole old list in memory for this to work. Both problems are solved by streams.
Jskalc
Good points, as always.
We only need to compare with previously-rendered list, correct? So I’d say it’s a job of
I don’t think I ever used such a construct.
__changed__to keep that old state around until the render is completed. We don’t need to keep keys for each comprehensions separately, we could calculate old keys based on__changed__. There’s one possible problem though - non-deterministic function used in list comprehension, based on a current state. I wonder how often this could happen?Correct me if I’m wrong, but isn’t it a small cost to pay? Currently we always traverse the whole list once and render all the elements underneath. With my suggestion we would need to traverse lists twice (assuming similar size post-update) but at the same time it could possibly skip rendering multiple children elements if they were not updated. Regarding memory, we only need to keep an old version around until render is done, so possibly not a long time. We already do it with maps that might contain lists as well. Also, depending on how exactly GC works, that object might be still around even if we don’t keep it in
__changed__. Last thing, an old list and a new one might reuse multiple elements, so it’s not that cost of keeping old list in memory doubles the consumption…A good question to ask: if it would make rendering 5-10% slower for certain use cases but reduce payload by 90%, is it worth it? I believe the answer is yes
josevalim
Yes,
__changed__only keeps the list (the assign) but not any transformation you apply on it. You have a good example on depending on other state that may change.You need to traverse the list twice and build maps based on keys. Then you need to compare the values of the keys: this may not be a cheap operation, to say two values are equal (which may be the majority if the list only changes a little), you need to fully traverse two values. For example, comparing two user structs will compare all fields and all associations recursively.
Plus the memory cost is not the cost of keeping two copies in memory. It is the cost that you need to keep the whole collection in memory after rendering until the next render. While streams can purge everything immediately after render.
Anyway, don’t let me be a party popper. If you feel you can tackle these limitations, go for it!
Jskalc
Is that so? I thought
__changed__is kept around between assigns inhandle_eventetc untilrenderand then discarded, so in most cases a few milliseconds.Anyway, I think I’m curious enough to try it
I might ask for some guidance later, if you won’t mind.
josevalim
You are correct. But in order to have something in
__changed__, you need to have the list of the previous render, which is the old state in__changed__. Streams do not require you to do it. In other words, streams allow you to remove the collection from assigns after render. Your approach requires you to keep the collection in assigns (so it goes to__changed__in the next render).Feel free to ask and reach out!
Zurga
What if you store a hash of the object that is represented by the key? It would not allow you to see how an element in the list has changed, but at least it is better than sending the entire list.
This would reduce the memory footprint only and might add more computational strain.
I’ve had no problems so far storing the associations as an assign after mount/update/handle_* and handling them with Stream. Just have a function that handles setting the assigns.
Having said that, I can understand why OP would want this.
Jskalc
I’m aware assign still has to be in memory. For me it’s not a downside - it’s exactly the same case as simply rendering a list comprehension without using streams.
Just to be clear about my goal - I want to optimize HTML diff size. My idea won’t help with memory usage, nor with performance (it might be slightly slower). But I believe it’s still worth a shot since IMO this is the last substantial improvement to be done in that area
I’m getting familiar with the HEEX engine right now, just real life gets in the way
josevalim
If we are going to ask people to add a :key to their comprehensions, wouldn’t it be better to ask them to use streams? I understand this is simpler to use but if the recommendation will be to prefer streams whenever possible, because they reduce HTML size, perform better, and use less memory, it is hard to argue its inclusion (and that’s not even considering complexity in the implementation).
LostKobrakai
I’m not sure I subscribe to the notion that streams are superior on all accounts. Stream work great for what they intend to do, but not everything is modelled to a insert/update/delete event system, which can just be forwarded into streams apis. Once that’s the case by now the suggestion has been “track the keys manually”, at which point I’d argue that no – streams are now no longer the better option to a built in key tracking option.
chrismccord
I just want to note that key tracking could be added to streams without holding the collection in in memory, at the cost of only holding the bookkeeping keys, so it’s not 1 vs the other in this regard.