When you build a file upload feature, you usually think about one progress bar: the one showing how much of your file has already made it to the server. But what if the real work starts only after the upload?

Maybe you’re transcoding a video, importing rows into a database, or sending the file to another service. In these cases, you end up with two progress sources:

  • The client upload (how much data is already sent) 📤
  • The backend processing (how much work the API has done so far) 🔄

Let’s talk about how to combine both in one beautiful, synchronized progress bar. 🎯


The problem 😬
#

Most apps stop updating progress once the upload finishes. The file is on the server, and now the user just… waits. It’s not great UX. Users want to see what’s happening - even if it’s just „Processing 73%".

The trick is to link your upload progress (client → backend) with your backend progress (API → client).


The idea 💡
#

The frontend tracks how many bytes have been sent. Meanwhile, the backend emits Server-Sent Events (SSE) with progress updates while it processes the uploaded file.

So we:

  • Report upload progress during the upload (0–50%) 🎬
  • Report backend progress via SSE (50–100%) 📈

Both values feed into one single IProgress<double> on the client side.


The client 🖥️
#

On the client, we wrap the upload stream in a ProgressableStreamContent so each chunk reports how much of the file is uploaded. Once the upload is done, the same progress reporter is used for backend events received via SSE.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var streamContent = new ProgressableStreamContent(stream, progress: progress);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");

var body = new MultipartFormDataContent
{
    { streamContent, "file", fileName },
};

var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
var responseStream = await response.Content.ReadAsStreamAsync();

var parser = SseParser.Create(responseStream, SseItemParser);
foreach (var sseItem in parser.Enumerate())
{
    if (sseItem.Data is ProgressEventMessage message)
    {
        progress.Report(message.Progress.Percentage);
    }
}

The magic part is that both upload and backend processing share the same progress reporter. The user sees one continuous bar moving from 0 to 100%. 🎉


The backend 🧩
#

On the backend, we stream the uploaded file to storage and push progress updates via SSE:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var throttler = new ProgressThrottler(TimeSpan.FromMilliseconds(200), minPercentageStep: 1.0);
var sseProgress = new Progress<ReportProgress>(p =>
{
    if (!throttler.ShouldSend(p.Percentage))
    {
        return;
    }

    var message = new ProgressEventMessage(p);
    channel.Writer.TryWrite(new EventMessageBase(EventMessageType.Progress, JsonSerializer.Serialize(message)));
});

Then, as we process the file (e.g. upload to S3 or do other work), we map its progress range to 50–100% and report that through the same channel. 🧮


Why this works so well 🏆
#

  • Single source of truth for progress: the client doesn’t have to guess what’s happening.
  • Real-time UX: users never see a „frozen" bar after the upload finishes.
  • Extensible: works with any long-running API action - imports, analysis, background tasks, etc.

And yes, technically, you’re „marrying" two progress bars. But in this case, it’s a happy marriage. 💍


Final thoughts 📝
#

This approach gives your users feedback through the entire lifecycle of an upload - from „sending file" to „processing complete".

You can adapt this pattern even if your backend doesn’t upload the file further. As long as the backend sends progress via SSE, you can merge it with the client’s upload progress and create a seamless experience. ✨