Memory usage in push event

Hello everyone.

I’m building a webapp with LiveView and ran into the following situation. On mount I load a Tabulator hook without any data. After the tabulator hook is mounted, it sends an event to the socket(“load”), and then I run an async task(load_data) to fetch the results and send them back to the hook via push_event.

The thing is, this query returns a few thousand results, and I thought that once the push_event completed the socket memory would be released, since I’m not storing the data in assigns. But that doesn’t seem to be the case.

I don’t load anything initially. The hook mounts, triggers an event, the event is processed in an async task (so it won’t block the LiveView), and when it finishes it sends the data back via push_event. Then the hook itself takes care of building the table, filters, views, and only comunicate with the LV when necessary to change values in the DB using push from the hook.

What am I doing wrong that the memory get so many spikes and does not reduce?

I have tried to reduce the data using chunks, instead of sending it all at once, but without success.

I know it is possible to use stream in the socket to avoid assigns in the socket, but using push event should not keep the data in the LV, isn’t it?

Here is a summary of the code: (I have put the List.duplicate to test in dev, since in prod I have use cases of 9000 rows, but in dev I simulate it with 300 real rows and increased it using duplicates).

While inspecting the dashboard I have noticed that when the socket mount, the process have 56.8 MB and also the module gets 82.2 MB of memory (the second one disappears after a some time). Couldn’t post the photo here, since I’m a new user.

def mount(_params, _session, socket) do
    account_id = socket.assigns.current_scope.user.last_account_id
    user_id = socket.assigns.current_scope.user.id
    if connected?(socket) do
      data_subscribe(account_id)
    end
    employees = list_employees_for_account(account_id)
    form = to_form(Action.create_action_changeset(%Action{}, %{}))
    {:ok,
     assign(socket,
       form: form,
       employees: employees,
       selected_products: [],
       checked: false,
       has_job_pending: false,
       show_task_modal: false,
       is_loading: true,
       progress: 0,
       options: []
     )}
  end

 def handle_event("load", _params, socket) do  
  account_id = socket.assigns.current_scope.user.last_account_id  
  load_data(account_id)  
  {:noreply, socket}  
end

  def load_data(account_id) do
    Task.async(fn ->
      data=
        from(i in Item,
          where: i.account_id == ^account_id,
          preload: [:item_metrics, :sku, actions: :employee]
        )
        |> Repo.all()
    |> Enum.flat_map(fn item ->
    List.duplicate(item, 20)
    end)
        |> Enum.map(&Formaters.format_item/1)
 
    options = unique_performance_options(data)
      {:data, {data, options}}
    end)
  end
 
  def handle_info({:load, _payload}, socket) do
    account_id = socket.assigns.current_scope.user.last_account_id
    load_data(account_id)
    {:noreply, socket}
  end
 
  def handle_info({ref, {:data, data}}, socket) do
    Process.demonitor(ref, [:flush])
    {data, options} = data
 
    {:noreply,
     socket
     |> assign(is_loading: false)
     |> assign(options: options)
     |> push_event("load_data", %{data: data})}
  end
​



You can try triggering a GC manually. The process heap will only decrease when garbage collection happens, which won’t necessarily trigger automatically after your callback finishes.

Modules cannot have memory. Only processes do. But you might be seeing a names process where the process name matches a module.

I’d suggest using the async apis of LV btw. They kinda do what you’re doing there, but without you needing to sweat the details.

Thanks for the feedback.
I didn’t use the async api because I only want to send the data when the hook is mounted and i don’t want to keep the data in the sockets, I don’t know if async api is the fit for it, do you?

Since I don’t need to attach data to the socket, I have imagined that the simple fact that using push_event would be enough to discard the memory after delivered it to the front end hook.

The start_async/4 function dispatches a Task similarly to what you’re doing. There is also assign_async/4 which builds on that functionality to do what you’re thinking (add the result to assigns).

With a GC memory is not freed until the GC actually runs. This is true of garbage collection in general, though Erlang’s GC is somewhat unique in that it can run per-process rather than globally.

If you assign it to the socket then you are keeping a reference, which means that it will never be GC’d (until some time after you remove the assign, of course).

It seems like it would be, replacing your manual tracking with the :is_loading assign.

There are two built in mechanisms for that:

  1. Temporary assigns: Phoenix.LiveView — Phoenix LiveView v1.1.12
  2. Streams: Phoenix.LiveView — Phoenix LiveView v1.1.12

If you’re using LiveView 1.1, you can also make use of the new APIs combining async and streams: Phoenix.LiveView — Phoenix LiveView v1.1.12

This last one, stream_async/4, seems to be the best fit for what I understood you’re trying to accomplish: load a possibly long list of items, without blocking the initial render, without keeping the items in memory.

The part of the async api makes sense. I’ll try it.
However what I want to accomplish is just send the data to the front end and make liveview forget about it.
But I am trying to use the data using hooks instead of streams.
That’s why I am curious about why the push_event from liveview does not frees memory as it is delivering it to the front end as the streams seems to do? (haven’t test streams to my solution yet, because I don’t know how to handle and make it work together with hooks)

assign_async, stream_async, etc don’t need to be called from mount, they can be called in response to an event just like you have (the "load" event). Is that what you meant by “use the data using hooks”?

So, recap:

  • Mount the LiveView
  • User clicks a button which fires an event
  • Server LV process start an async task (e.g. with start_async)
  • Server pushes event to the client with resulting data. Even if you started a new task, the data needs to be copied from the task process to the LV, causing the LV memory usage to go up temporarily. GC is per process.
  • You can handle the event into a client hook (JS code) and do whatever you like to the data
  • If you want to render the data instead, it is probably better to use streams or temporary_assigns.

The push_event strategy should free the memory, it just won’t happen immediately. Take a look at Erlang’s GC docs if you want to learn more.

You can force garbage collection to see if it frees the memory but this is not something you should be doing under normal circumstances.