Upcoming masterclass schedule: The Sacred Armour of Shiva starting on 24 October 2026 and The Secret of Breath starting on 25 October 2026


Real-Time at Last: SSE and SignalR in ASP.NET Core

Part 2 of 2 — Part 1 covered the client-driven techniques: page refresh, XHR, Ajax/fetch, and polling. This post picks up where that one left off.

Quick Recap

In Part 1, we walked through the pre-real-time era of server status updates: full page refresh, XMLHttpRequest, Ajax and fetch(), and short/long polling. Every one of those techniques shares the same underlying limitation — the client has to keep asking the server whether anything has changed. Even long polling, which holds a request open until there's news, is really just a more patient version of "are we there yet?"

What all of that machinery was really reaching for is a way for the server to simply tell the client when something happens, the moment it happens. That's what this post covers: Server-Sent Events (SSE) and SignalR, the two mainstream ways to get genuine server push in ASP.NET Core.

We'll keep using the same running example from Part 1: a "Generate Report" job that takes 20–30 seconds, with a background queue tracking its status.

Approach 4: Server-Sent Events (SSE)

SSE is a deliberately simple idea: the client opens a single long-lived HTTP connection, and the server streams a sequence of text-based messages down it whenever it has something new to say. It's one-directional — server to client only — and it's built natively into the browser through the EventSource API, so there's no client library to install.

This maps almost perfectly onto our status-update scenario: the server has periodic progress updates to report, and the client just needs to listen. In ASP.NET Core, an SSE endpoint is a minimal API action that writes directly to the response stream instead of returning a single JSON payload:

app.MapGet("/reports/{jobId}/stream", 
async (string jobId, IReportQueue queue, 
HttpContext ctx) =>
{
    ctx.Response.Headers.ContentType = 
    "text/event-stream";
    await foreach (var update in 
    queue.WatchStatus(jobId, ctx.RequestAborted))
    {
        await ctx.Response.WriteAsync
        ($"data: {JsonSerializer
            .Serialize(update)}\n\n");
        await ctx.Response.Body.FlushAsync();
    }
});

WatchStatus here is an IAsyncEnumerable<Status> that yields a new value each time the job progresses — the await foreach naturally streams each one down the open connection as it occurs.

On the client, EventSource handles the connection and parsing for you:

const source = new EventSource
(`/reports/${jobId}/stream`);
source.onmessage = e => {
  const { status, progress } = 
  JSON.parse(e.data);
  updateUI(status, progress);
  if (status === "Completed") 
  source.close();
};

The appeal of SSE is how little there is to it. The protocol is simple, it rides on plain HTTP so it sails through the proxies and firewalls that sometimes cause trouble for WebSocket-based approaches, and the browser's EventSource API handles automatic reconnection for you if the connection drops — you don't have to write that logic yourself. It's also lightweight, since there's no extra protocol negotiation or client library involved. The trade-offs are worth knowing, though: SSE is one-directional only, so if the client ever needs to send data back over the same channel, you're back to a separate request. Browsers also historically capped the number of concurrent HTTP/1.1 connections per domain, which could bite you if a page opens several SSE streams at once — HTTP/2 mostly resolves this, but it's worth being aware of. And compared to SignalR, SSE is very much "bring your own infrastructure": there's no built-in concept of groups, broadcasting to multiple users, or connection state management — if you need those, you're building them yourself.

Approach 5: SignalR

SignalR is ASP.NET Core's full-featured real-time framework. Rather than committing to one transport, it negotiates the best one available — WebSockets when possible, falling back to SSE or long polling automatically if not — and wraps the whole thing in a much richer programming model built around hubs.

A hub is a class the server uses to push messages to connected clients, and clients call back into it too, since SignalR is bidirectional by design:

public class ReportHub : Hub
{
    public async Task JoinJobGroup
    (string jobId) =>
        await Groups.AddToGroupAsync
        (Context.ConnectionId, jobId);
}

From the background job, pushing a progress update to everyone watching that job is a single call:

await hubContext.Clients.Group(jobId)
.SendAsync("ReportProgress", 
new { status, progress });

And the JavaScript client subscribes using the @microsoft/signalr package:

const connection = new signalR.HubConnectionBuilder()
.withUrl("/reportHub").build();
connection.on("ReportProgress", 
({ status, progress }) => 
updateUI(status, progress));
await connection.start();
await connection.invoke
("JoinJobGroup", jobId);

For scale-out scenarios — multiple server instances behind a load balancer — Azure SignalR Service or a Redis backplane can distribute SignalR messages across instances, so a message sent from one instance can reach clients connected to another. Redis still requires appropriate session affinity in the usual configuration, while Azure SignalR Service handles the connection layer for you.

The strengths of SignalR come from exactly this richness. Communication is genuinely two-way, the transport negotiation and automatic fallback mean you rarely have to think about which underlying protocol is actually in use, and reconnection handling comes built in rather than something you write yourself. Groups and broadcasting are first-class concepts, which matters as soon as more than one user needs to see the same update — exactly our report-progress scenario if, say, a manager and the report's author are both watching. And because it fits naturally into an existing ASP.NET Core codebase, it tends to feel familiar to .NET developers. None of that comes for free, though: SignalR is a heavier dependency than a plain SSE endpoint, with more moving parts, and at scale — multiple server instances — it needs a backplane like Redis or Azure SignalR Service to keep connection state consistent, which is extra infrastructure to run and pay for. For a single simple "is my one report done yet" status check with one client, it can be more machinery than the problem actually calls for.

A Note for Blazor Developers

If you're building with Blazor Server, there's a detail worth knowing: Blazor Server already runs on SignalR internally. Every UI update, event, and re-render travels over a persistent SignalR connection — called a circuit — between the browser and the server. That means you likely don't need to build a separate hub at all to push a status update into a Blazor Server page; you can subscribe to your background job's status notifications from the component, update a bound property, and invoke StateHasChanged():

private string status = "Running";
protected override void OnInitialized()
{
    reportQueue.OnStatusChanged += (s, e) =>
    {
        status = e.Status;
        InvokeAsync(StateHasChanged); 
        // marshal back to the circuit's sync context
    };
}

Under the hood, that update reaches the browser exactly the way a hand-written SignalR message would — you're just getting it for free as part of the framework.

Blazor WebAssembly, by contrast, runs entirely client-side and doesn't get this for free — it's simply another HTTP client, and can use any of the techniques from either part of this series: HttpClient calls for polling, EventSource via JS interop for SSE, or the SignalR client exactly as a JavaScript app would.

Comparing All Five Approaches

Technique Direction Latency Server Cost Complexity Best For
Page Refresh Client pull Very high Very low Very low Trivial internal tools, no JS
XHR / Ajax / fetch (short polling) Client pull Medium–high Low–medium (scales with poll frequency) Low Occasional checks, simple UIs
Long Polling Client pull, server holds Low Medium (held connections) Medium Near-real-time without new infra
SSE Server push Low Low–medium Medium One-directional live updates, simple infra
SignalR Bidirectional push Very low Medium–high (more at scale) Medium–high Multi-user, two-way, needs groups/reconnection

Choosing the Right Tool

The right choice really does depend on the shape of the problem rather than which technology is newest. A low-traffic internal tool with no JavaScript budget is still well served by a plain page refresh — reaching for anything fancier there is solving a problem you don't have. An occasional, low-frequency status check is a perfectly good fit for fetch()-based polling; it's simple to build, simple to debug, and the extra latency rarely matters if the job takes minutes anyway. When you need updates to feel closer to instant, and the communication only ever flows one way — server informing client — SSE hits a sweet spot: real push, minimal infrastructure, no new client library. Once you need two-way communication, or multiple users watching the same event, or the reconnection and grouping logic that comes with a serious multi-client real-time feature, SignalR earns its extra weight. And if you're already building in Blazor Server, that decision is partly made for you — the SignalR circuit is already open, so pushing a status update is often just a property change and a StateHasChanged() call away.

Conclusion

Looked at end to end, the progression from page refresh through polling to SSE and SignalR isn't really a story of one technology replacing another — it's a story of each generation solving the specific limitation of the one before it, while introducing new trade-offs of its own. Full refresh was replaced because it was too coarse; polling was refined because it was too wasteful and too slow; and push-based techniques emerged because even the best polling is still, fundamentally, guessing. None of that makes SignalR the automatically "correct" answer today. The best engineering choice is still the one that matches your actual requirements — how many clients, how real-time it truly needs to be, whether communication needs to flow both ways — rather than whichever tool happens to be the newest one in the toolbox.

That’s all for now. May your intention be clear and your mind be still. With this quiet wish, I rest my pen and return to the silence.


Author : Bipin Joshi
Bipin Joshi is an independent software consultant, trainer, and author, specializing in Microsoft web development technologies. Having embraced the yogic way of life, he also mentors select individuals in Ajapa Gayatri and allied meditative practices. Blending the disciplines of code and consciousness, he has been meditating, programming, writing, and teaching for over 31 years. As a prolific author, he shares his insights on both software development and yogic wisdom through his websites.

Posted On : 23 September 2026