When you trigger a long-running operation through an API — like generating a document, compiling a report, or uploading a processed file to cloud storage, users hate waiting in silence.
Wouldn’t it be great if your backend could continuously stream progress updates back to the browser in real time, without needing WebSockets or polling?

That’s exactly what this post is about.

We’ll explore how to build a robust, async-safe SSE architecture in ASP.NET Core to stream progress updates for any background task, and how to avoid common pitfalls like interleaved writes and lost events.


🎯 The Use Case
#

Let‘s take a typical example:

A user triggers a document generation request via your API.
The server generates the document, uploads it to a storage service,
and streams progress updates back to the client during each phase.

At the end, the client receives either a success event with the storage path,
or an error event if something failed.


🧩 Core Components
#

We‘ll build our system around three reliable helpers:

SseResponseWriter handles safe, thread-protected writes to the SSE response stream.

ProgressThrottler ensures we only send updates every few milliseconds or after meaningful percentage changes.

Channel<EventMessageBase>queues events to decouple your long-running task from the network I/O layer.

These three pieces work together to ensure your streaming response is smooth, structured, and cancellation-safe.


💡 Why We Use a Channel
#

When you generate a document and upload it to a storage service, multiple async operations run in parallel — sometimes in background threads.

If all of them tried to write directly to the HTTP response, you would quickly hit:

  • race conditions
  • out-of-order JSON messages
  • broken connections when the controller finishes before the last event is sent

That‘s why we use a Channel — a thread-safe in-memory queue from System.Threading.Channels .

✅ Your background tasks write progress, success, and error messages into the channel.
✅ A single background consumer reads and sends them through the SseResponseWriter.
✅ The controller only returns once all messages are sent and the connection is cleanly closed.


⏱️ Progress Throttling Done Right
#

If your API processes thousands of steps or bytes, you don’t want to send every single one to the browser.
Our ProgressThrottler handles this elegantly using:

  • time-based throttling (for example, every 200 milliseconds)
  • percentage-based throttling (for example, on at least 1 percent change)

It uses a monotonic clock (Stopwatch.GetTimestamp) to avoid bugs caused by system time changes. This means your throttling logic will remain stable even on long-running tasks or VMs.


🧭 The ProgressThrottler
#

Here’s the full implementation you can directly reuse:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/// <summary>
/// Helper class to throttle progress updates based on time intervals and percentage steps.
/// Uses a monotonic clock (Stopwatch) to avoid issues when system time changes.
/// Thread-safe.
/// </summary>
public sealed class ProgressThrottler
{
    private readonly TimeSpan _minInterval;
    private readonly double _minPercentageStep;
    private long _lastSentTimestamp;
    private double _lastPercentage = -1;
    private readonly object _sync = new();

    public ProgressThrottler(TimeSpan minInterval, double minPercentageStep = 0)
    {
        if (minInterval < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(minInterval));
        if (minPercentageStep < 0) throw new ArgumentOutOfRangeException(nameof(minPercentageStep));

        _minInterval = minInterval;
        _minPercentageStep = minPercentageStep;
    }

    public bool ShouldSend(double currentPercentage)
    {
        lock (_sync)
        {
            var nowTs = Stopwatch.GetTimestamp();

            if (_lastSentTimestamp == 0)
            {
                _lastSentTimestamp = nowTs;
                _lastPercentage = currentPercentage;
                return true;
            }

            if (currentPercentage < _lastPercentage)
            {
                return false;
            }

            var elapsed = Stopwatch.GetElapsedTime(_lastSentTimestamp, nowTs);
            var percentageDiff = Math.Abs(currentPercentage - _lastPercentage);

            bool timeThresholdMet = elapsed >= _minInterval;
            bool percentageThresholdMet = _minPercentageStep > 0 && percentageDiff >= _minPercentageStep;

            if (timeThresholdMet || percentageThresholdMet || currentPercentage >= 100.0)
            {
                _lastSentTimestamp = nowTs;
                _lastPercentage = currentPercentage;
                return true;
            }

            return false;
        }
    }
}

You can tune the timing and precision to fit your workload.
For example, new ProgressThrottler(TimeSpan.FromMilliseconds(200), 1.0) will send at most five updates per second or whenever the percentage increases by one percent.


⚙️ Example Controller
#

Let’s take a simplified version of our actual implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
[HttpPost("generate-document")]
public async Task<IActionResult> GenerateDocumentAsync(
    [FromHeader(Name = "X-Sending-App")] string sender,
    [FromQuery][Required] string documentName,
    [FromQuery] string? targetFolder = null)
{
    if (!long.TryParse(Request.Headers.ContentLength?.ToString(), out var totalBytes))
        return BadRequest("Missing or invalid Content-Length");

    using var sseWriter = new SseResponseWriter(HttpContext.Response);
    var cancellation = HttpContext.RequestAborted;

    // Create an unbounded channel for event messages
    var channel = Channel.CreateUnbounded<EventMessageBase>(new UnboundedChannelOptions
    {
        SingleReader = true,
        SingleWriter = false
    });

    // Background task to process messages and send them via SSE
    var sseTask = Task.Run(async () =>
    {
        await foreach (var msg in channel.Reader.ReadAllAsync(cancellation))
        {
            await sseWriter.WriteEventAsync(msg, cancellation);
        }
    }, cancellation);

    // Throttle progress updates
    var throttler = new ProgressThrottler(TimeSpan.FromMilliseconds(200), minPercentageStep: 1.0);
    var progress = new Progress<ReportProgress>(p =>
    {
        if (!throttler.ShouldSend(p.Percentage)) return;

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

    // Generate document (simulate long-running process)
    var generatedDocument = await documentService.GenerateAsync(documentName, progress, cancellation);

    // Upload generated file to external storage
    var uploadProgress = new Progress<ReportProgress>(p =>
    {
        var adjusted = p with { Percentage = 50 + p.Percentage * 0.5 };
        progress.Report(adjusted);
    });

    var uploadResult = await storageService.UploadFileAsync(sender, targetFolder, generatedDocument, uploadProgress, cancellation);

    // Send final success message
    channel.Writer.TryWrite(new EventMessageBase(EventMessageType.Success,
        JsonSerializer.Serialize(new SuccessEventMessage(uploadResult.RelativeFilePath))));

    channel.Writer.Complete();
    await sseTask;
    await HttpContext.Response.CompleteAsync();

    return Empty;
}

🧠 Key Takeaways
#

🧱 1. Use Channels to Decouple Event Flow
#

Channels let you queue up progress and result messages safely and keep network I/O off your business logic thread.

🧩 2. Protect SSE Writes with a Semaphore
#

ASP.NET Core’s HttpResponse isn’t thread-safe for concurrent writes.
 Our SseResponseWriter ensures only one event is written at a time.

⚙️ 3. Always Throttle Updates
#

Throttle by time and by percentage step to keep updates smooth without flooding the network.

🕒 4. Use Stopwatch Instead of DateTime.UtcNow
#

It’s a monotonic clock that never jumps backward or forward, essential for stable throttling.

#

🧵 5. Wait for All SSE Writes Before Closing

The controller should only finish once the channel is drained and all messages are flushed.
Otherwise, the client might miss the final event.


🧭 The SseResponseWriter
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public sealed class SseResponseWriter : IDisposable
{
    private readonly HttpResponse _response;
    private readonly SemaphoreSlim _writeLock = new(1, 1);

    public SseResponseWriter(HttpResponse response)
    {
        _response = response ?? throw new ArgumentNullException(nameof(response));
        _response.Headers.CacheControl = "no-cache";
        _response.Headers.Connection = "keep-alive";
        _response.Headers["X-Accel-Buffering"] = "no";
        _response.ContentType = "text/event-stream";
    }

    public async Task WriteEventAsync(EventMessageBase message, CancellationToken token)
    {
        await _writeLock.WaitAsync(token);
        try
        {
            await _response.WriteAsync($"event: {message.Type.ToString().ToLowerInvariant()}\n", token);
            await _response.WriteAsync($"data: {message.Body}\n\n", token);
            await _response.Body.FlushAsync(token);
        }
        finally
        {
            _writeLock.Release();
        }
    }

    public void Dispose() => _writeLock.Dispose();
}

💬 Client-Side Example
#

Here’s how to consume those events from your frontend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const evtSource = new EventSource("/api/v1/documents/generate-document?documentName=report.pdf");

evtSource.addEventListener("progress", e => {
  const data = JSON.parse(e.data);
  console.log(`Progress: ${data.progress.percentage}%`);
});

evtSource.addEventListener("success", e => {
  const data = JSON.parse(e.data);
  console.log("Document ready at:", data.relativeFilePath);
  evtSource.close();
});

evtSource.addEventListener("error", e => {
  console.error("Error:", e.data);
  evtSource.close();
});

✅ Final Thoughts
#

This approach is not just about document generation, it’s a general pattern for any long-running server process:

  • Generating PDFs or reports
  • AI model inference
  • Video or audio processing
  • Background imports or exports

By combining Channels, Throttling, and SSE, you get real-time progress reporting that’s reliable, scalable, and surprisingly simple.

You don’t need WebSockets.
You don’t need SignalR.
Just smart use of built-in .NET features.

Happy coding, and may your uploads always reach 100% — smoothly.