Async Processing in LiveView

Image by Annie Ruygt

In this post we talk about how to perform async processing from a LiveView using easy Elixir concurrency. Fly.io is a great place to run your Elixir applications! Check out how to get started!

Last month Chris McCord developed an amazing single-file example for doing image classification using Bumblebee, Nx and LiveView. We can see all the parts together in one place, and run the example just with a single command… it must be said, it's impressive!

The example works like this: You upload an image and the process for classifying images with Bumblebee runs in an asynchronous process while a small working indicator is shown. This means the user is not blocked and can still interact with the page while the process chugs away performing the work. Once the processing is done, the indicator disappears and the results are printed on the screen:

That little trick of asynchronously working in the background while being handled all from the server was just so elegantly simple and clean that it deserves a deeper look!

Problem

How can we run async processing in a LiveView? How can we return results of an async process to the LiveView?

Solution

The main actor is the Task module, we can use it to spawn processes to complete a specific task.

Sometimes we need to perform some processing asynchronously, and we need to wait for the result. This is possible with a couple of functions: Task.async/1 launches a process that, when it finishes its work, sends a message with the result to the caller. And Task.await/2 waits for the task's message and returns the result:

task = Task.async(fn -> 1 + 2 end)     
# %Task{
#  owner: #PID<0.508.0>,
#  pid: #PID<0.518.0>,
#  ref: #Reference<0.4260127598.4204593153.204028>
#}
result = Task.await(task)  
#iex()> 3

Let's take the image classification example we mentioned at the beginning.

Once the user uploads an image, the classification process starts. Since it is an expensive operation that can take time to complete, a task is spawned to complete that process.

While the processing is taking place a :spinner function component —defined in the same file— is displayed with the help of the assign :running. As long as the task is not completed, the value of this assign is true and the spinner is shown conditionally:

def handle_progress(:image, entry, socket) do
  ...
  Task.async(fn -> 
    Nx.Serving.batched_run(PhoenixDemo.Serving, image) 
  end)
  ...
  {:noreply, assign(socket, running: true)}
end

When the task is spawned using Task.async/1, a couple of things happen in the background. The new process is monitored by the caller —our LiveView—, which means that the caller will receive a {:DOWN, ref, :process, object, reason} message once the process it is monitoring dies. And, a link is created between both processes.

We're going to talk more about some considerations related to this last point in a couple of minutes, but now we have one more question to answer: how can we get the response of the task in the LiveView? Well that's simple.

We no longer need to use Task.await/2 , our LiveView already has the ability to receive messages from other processes using the handle_info/2 callback:

def handle_info({ref, result}, socket) do
  Process.demonitor(ref, [:flush])
  %{predictions: [%{label: label}]} = result
  {:noreply, assign(socket, label: label, running: false)}
end

The received message contains a {ref, result} tuple, where ref is the monitor's reference. We use this reference to stop monitoring the task —and not receive a message if it dies—, since we received the result we needed from our task and we can discard an exit message.

Finally we set the result in the assign :label to display it, and we hide our spinner by changing the content of the assign :running.

An elegant solution, right? Just a couple lines of simple Elixir concurrency to delegate the work and limit the responsibilities of our LiveView.

We don't even have to worry if the user closes the browser tab! The process dies just like the LiveView, and the work is automatically cancelled. No resources are spent on a process from which nobody expects the result anymore.

José shows us this pattern in his recent video!

Additional Considerations

1) When a task is spawned using Task.async/2, it is linked to the caller. Which means that both processes have a relationship: if one crashes, the other does too.

We must take it into account. If we don't have control over the result of the task, and we don't want our LiveView to crash if the task crashes, we must use a different alternative to launch our task. We can use Task.Supervisor.async_nolink/3 to make sure that our LiveView won't die even if the task crashes and that the error will be reported.

2) We need to think about what kind of work we want to do asynchronously.

In a scenario where we're doing something that takes time and we don't save the result, we don't want the job to keep going if the user leaves. So this solution is a good fit.

But, if we are building some report that must be generated even if the user closes the browser tab, then, this may not be the right solution.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It's really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!  

Tell us, what other options have you used to do async work in LiveView?