- Published on
What I Learned Building Real-Time Upload Progress with Phoenix LiveView
Hello,
In this post, I want to share some practical lessons I learned while building a real-time CSV upload and parsing system. If you are a developer who is curious about practical LiveView work—especially if you are currently deciding whether you need a complex JavaScript framework to build highly interactive, "real-time" features—this post is for you.
We recently ran into a frustrating user experience issue with our CSV import process, and solving it taught me a lot about the boundary between Phoenix LiveView processes, GenServers, and browser rendering bottlenecks.
The Problem: "Is This Thing Working?"
Our system allows users to upload large CSV files for processing. Originally, we did not have a clear visual indicator for the upload and parsing progress. Users had no way to tell if a large CSV import was actively working or completely stuck.
As you can probably guess, this led to bad user behavior. Out of frustration, users would hit refresh mid-upload. This didn't just cancel their view; it actually triggered duplicate imports on the backend, creating messy data situations and unnecessary server load. We needed a way to show users exactly what was happening in real-time.
Why Inline Parsing is a Trap
When implementing file uploads in LiveView, it is incredibly tempting to keep things simple. You might think about writing the parsing logic inline, directly within the LiveView process that handles the socket connection.
However, doing this is a mistake. If you parse a large file row-by-row inside the LiveView process, any slow row—whether due to bad data, database latency, or external retry logic—will block the entire socket. Because the LiveView process is blocked, it cannot handle any other incoming user events. If the user gets tired of waiting and tries to navigate away or click a cancel button, the UI will feel completely frozen because the socket is busy processing rows.
To keep our LiveView process lightweight and responsive to user inputs, we had to decouple the upload from the parsing.
The Solution Architecture
We decided to separate concerns using three core components of the Elixir ecosystem:
- Phoenix LiveView Native Uploads: We used LiveView's built-in
allow_uploadfunctionality to handle the initial file ingest. - A Dedicated GenServer: Once the upload is accepted, we offload the heavy row-by-row parsing to a separate GenServer process. This keeps the LiveView process free to handle standard user interaction.
- Phoenix PubSub: The parsing GenServer broadcasts progress messages back to the LiveView, which dynamically updates a progress bar for the user.
1. Wiring up the Upload
First, we configure the LiveView to allow CSV uploads and handle the entry progress. When the upload is completed, we notify our system to begin parsing.
# handle_progress/3 callback wiring for allow_upload
def handle_progress(:csv_import, entry, socket) do
if entry.done? do
# Once the file is uploaded, we can start our background GenServer
# and pass the path for row-by-row parsing.
end
{:noreply, socket}
end
2. Offloading to the GenServer
The GenServer handles the actual work of reading the file and running the import logic. As it loops through the rows, it casts progress updates back to the UI.
# GenServer.cast broadcasting {:progress, row_count, total} tuples
def handle_cast({:process_chunk, rows, current_count, total}, state) do
# ... process the rows ...
Phoenix.PubSub.broadcast(
MyApp.PubSub,
"import:#{state.import_id}",
{:progress, current_count + length(rows), total}
)
{:noreply, state}
end
3. Updating the LiveView UI
Back in the LiveView, we subscribe to the PubSub channel matching our import ID. Whenever the GenServer broadcasts a update, our LiveView receives the message, updates its assigns, and automatically re-renders the progress bar for the user.
# handle_info/2 in the LiveView updating assigns from PubSub messages
def handle_info({:progress, row_count, total}, socket) do
socket =
socket
遊びassign(:row_count, row_count)
|> assign(:total_rows, total)
|> assign(:progress_percentage, round((row_count / total) * 100))
{:noreply, socket}
end
With this setup, we have a reactive, real-time progress bar—no polling, and absolutely zero custom JavaScript required.
The Trap: Flooding PubSub
When I first built this, I made a classic mistake: I configured the parsing GenServer to broadcast a progress update on every single row parsed.
If you are importing a CSV with thousands of rows, this means sending thousands of PubSub messages in a matter of seconds. I quickly noticed that the browser would start to lag heavily during imports.
At first, I assumed the Elixir server was struggling under the load. However, after running a frontend profiler session, I realized the server was completely fine. The bottleneck was entirely in the browser. The browser was being flooded with socket updates and simply couldn't keep up with the rapid pace of DOM re-renders for the progress bar.
To fix this, we implemented batching. Instead of broadcasting on every row, we limited PubSub broadcasts to occur every 500 rows. This small tweak instantly eliminated the browser lag while keeping the progress bar updates looking smooth and responsive to the human eye.
The Outcome
This architecture didn't change the actual time it takes to parse and import a CSV file. The backend performance remained essentially the same.
However, the change in user experience was night and day. Once users could see the progress bar row counts ticking up in real-time, they stopped refreshing the page. As a direct result, our support tickets regarding "frozen" uploads dropped to exactly zero.
It was a fantastic reminder of how powerful LiveView is. We achieved a highly interactive, real-time feature that kept our server responsive and our users informed, all without writing a single line of JavaScript or setting up a complex client-side framework.
If you are dealing with long-running backend processes, I highly recommend offloading the work to a GenServer and using PubSub to stream incremental updates back to your LiveView. Just remember to batch your updates so you don't overwhelm your users' browsers!
Thank you for reading, and let me know if you have any questions about structuring background work in your LiveView apps.
Best,