Skip to content
MA MagicAjax.NET /* dev archive */
Open menu
AJAX & Modern Web ·

Server-Sent Events vs SignalR in ASP.NET Core 10 and When Each One Makes Sense

Written by Alex

Server-Sent Events vs SignalR in ASP.NET Core 10 and When Each One Makes Sense

If the server needs to push updates to the browser and the browser only occasionally sends something back through normal requests, use Server-Sent Events. Since .NET 10, that takes a few lines in a Minimal API endpoint, and it works through standard HTTP infrastructure with no client library. Use SignalR when traffic genuinely flows both ways at high frequency, when you need groups and targeted delivery across many server instances, or when you want a managed scale-out path and automatic transport fallback.

Most live-update features in business applications, such as notifications, order status, progress bars, dashboards and streamed AI responses, fall into the first category. Many teams have been using SignalR for them anyway, because until recently ASP.NET Core had no convenient built-in way to send an event stream. That has changed, and the decision deserves a fresh look.

Server-Sent Events vs SignalR in ASP.NET Core 10 and When Each One Makes Sense

From UpdatePanel polling to event streams

For anyone who maintained ASP.NET applications in the WebForms era, the problem is familiar. The classic approach to a "live" page was a Timer control triggering an UpdatePanel refresh every few seconds: a full partial postback, ViewState included, just to ask whether anything had changed. It worked, it wasted enormous amounts of bandwidth, and it put a latency floor equal to the polling interval on every update.

The web platform's answer arrived in two forms. WebSockets gave a persistent, bidirectional connection over its own protocol after an HTTP upgrade. Server-Sent Events took a more modest approach: a single long-lived HTTP response with the text/event-stream content type, into which the server writes events as they happen. The browser's EventSource API reads that stream, dispatches events and reconnects automatically if the connection drops.

SignalR, introduced for ASP.NET in 2013 and rebuilt for ASP.NET Core, wrapped all of this in an abstraction. It negotiates the best available transport (WebSockets, then Server-Sent Events, then long polling), adds a hub programming model with method calls in both directions, and handles connection groups and scale-out. For a decade it was the default answer to "real-time in .NET", largely because doing anything else meant writing your own streaming response code.

What .NET 10 added

ASP.NET Core in .NET 10 introduced TypedResults.ServerSentEvents, a Minimal API result that takes an IAsyncEnumerable<T> and writes each item to the response as a server-sent event. For control over each event, you yield SseItem<T> from the System.Net.ServerSentEvents namespace, which lets you set the event type, an event ID and the reconnection interval the client should use. Plain values are written as strings or serialised with the application's configured JSON serialiser.

A production-shaped endpoint looks like this. OrderFeed and OrderUpdate stand in for your own application types:

csharp

using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;

app.MapGet("/orders/stream", (
    HttpRequest request,
    OrderFeed feed,
    CancellationToken cancellationToken) =>
{
    // Sent automatically by EventSource when it reconnects.
    long? lastSeen = long.TryParse(request.Headers["Last-Event-ID"], out var id)
        ? id
        : null;

    return TypedResults.ServerSentEvents(StreamUpdates(feed, lastSeen, cancellationToken));
});

static async IAsyncEnumerable<SseItem<OrderUpdate>> StreamUpdates(
    OrderFeed feed,
    long? lastSeen,
    [EnumeratorCancellation] CancellationToken cancellationToken)
{
    await foreach (var update in feed.ReadSince(lastSeen, cancellationToken))
    {
        yield return new SseItem<OrderUpdate>(update, eventType: "order")
        {
            EventId = update.Sequence.ToString(),
            ReconnectionInterval = TimeSpan.FromSeconds(5)
        };
    }
}

Three details matter more than they look.

The CancellationToken is essential. In a Minimal API handler it is bound to HttpContext.RequestAborted, so it fires when the client disconnects. Without it, a closed browser tab leaves the server enumerating an infinite stream for nobody, holding memory and possibly database resources. With [EnumeratorCancellation], the token reaches the async iterator and the loop ends cleanly.

Event IDs make reconnection lossless, but only if you implement resumption. When a connection drops, EventSource reconnects and sends the last received ID in a Last-Event-ID request header. The browser does that automatically. Replaying what the client missed is your job. The feed must be able to read "everything after sequence N", which in practice means monotonically increasing IDs backed by a store or a bounded in-memory buffer. Without IDs, a reconnect silently loses whatever happened while the client was disconnected.

ReconnectionInterval controls how long clients wait before reconnecting. It is written as the SSE retry field. A longer interval helps protect a recovering server from a thundering herd of simultaneous reconnections after a deployment.

The browser side, and where EventSource stops being enough

For read-only streams, the client needs no library:

js

const source = new EventSource('/orders/stream');

source.addEventListener('order', (event) => {
  const update = JSON.parse(event.data);
  renderOrder(update);
});

source.addEventListener('error', () => {
  if (source.readyState === EventSource.CLOSED) {
    showOfflineBanner(); // the browser has given up; reconnect manually or reload
  }
  // readyState CONNECTING means the browser is retrying on its own
});

Some behaviour here catches teams by surprise.

Named events do not reach onmessage. If the server sets an event type, as eventType: "order" does above, the client must listen for that exact name with addEventListener. A handler on onmessage only receives events without a type. This is the most common "the stream works in curl but nothing arrives in the browser" bug.

Some failures stop reconnection permanently. EventSource retries after network errors. But if the server responds with a non-200 status or a content type other than text/event-stream, the browser fails the connection and does not retry. A login redirect to an HTML page, a 401, or an error page from a proxy all produce this state. You can also use it deliberately: responding with 204 No Content tells the client to stop.

EventSource can only make GET requests without custom headers. It sends cookies, including cross-origin with withCredentials: true, but it cannot set an Authorization header or send a request body. That fits cookie-authenticated applications, and it is a problem for token-authenticated APIs. Tokens in query strings end up in server and proxy logs, which is exactly where they should not be.

That last limitation is why many streaming AI interfaces do not use EventSource at all. A chat request needs a POST body (the prompt and conversation) and usually a bearer token. The response is still in SSE format, which is what the major model APIs use for streaming, but the client reads it with fetch and a streamed response body:

js

async function streamCompletion(prompt, { token, signal, onData }) {
  const response = await fetch('/chat/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ prompt }),
    signal, // an AbortController lets the user stop generation
  });

  if (!response.ok) throw new Error(`Stream failed with HTTP ${response.status}`);

  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;

    let boundary;
    while ((boundary = buffer.indexOf('\n\n')) !== -1) {
      const rawEvent = buffer.slice(0, boundary);
      buffer = buffer.slice(boundary + 2);

      const data = rawEvent
        .split('\n')
        .filter((line) => line.startsWith('data:'))
        .map((line) => line.slice(5).replace(/^ /, ''))
        .join('\n');

      if (data) onData(data);
    }
  }
}

This is a simplified parser. It assumes \n line endings, and a full implementation must also handle \r\n, the id: and retry: fields, and comment lines. What you gain is full control over method, headers and cancellation. What you lose is everything EventSource did for free, above all automatic reconnection with Last-Event-ID. For AI completions that is usually acceptable: a broken generation is typically retried as a whole rather than resumed. For long-lived event feeds it is a real loss, and cookie authentication with EventSource is usually the better choice.

What SignalR still does that SSE does not

Native SSE support does not make SignalR obsolete. It narrows the set of problems that justify it.

Frequent client-to-server messages. SSE is one-way. Clients send data through ordinary HTTP requests, and each carries full request overhead and goes through the normal authentication and middleware pipeline. For occasional actions that is fine and often simpler. For collaborative editing, multiplayer interactions, or anything sending many small messages per second from the client, a single WebSocket connection carrying hub method calls in both directions is far more efficient.

Groups and targeted delivery at scale. SignalR's Clients.Group("order-42") and Clients.User(userId) handle subscription bookkeeping for you. With SSE you implement it yourself: typically a subscription registry mapping users or topics to per-connection channels, with cleanup when a connection ends.

Scale-out. A message published on server A must reach a client connected to server B. SignalR supports this with a Redis backplane, or by offloading connections entirely to Azure SignalR Service. With SSE, you build the same distribution yourself on top of a message broker or pub/sub system, feeding each instance's local subscriptions.

Transport negotiation and a typed client. SignalR clients for JavaScript, .NET, Java and other platforms handle reconnection, transport fallback and serialisation, including MessagePack. SSE needs no client library in browsers, but outside the browser you need a parser. In .NET, System.Net.ServerSentEvents includes one.

The following table summarises the decision:

RequirementSSE (.NET 10)SignalRServer-to-client updates onlyIdealWorks, heavierFrequent client-to-server messagesVia separate HTTP requestsIdealBrowser client libraryNone neededRequiredResumption after disconnectBuilt into protocol via event IDs, if you implement replayReconnection handled; missed messages are not replayed by defaultGroups and per-user deliveryBuild yourselfBuilt inMulti-server scale-outBuild on a brokerRedis backplane or Azure SignalR ServiceWorks through standard HTTP toolingYesYes, but WebSockets need proxy support

One row deserves emphasis. Developers often assume SignalR guarantees delivery across reconnects. It re-establishes the connection, but messages sent while a client was disconnected are not replayed unless the application implements that. SSE's Last-Event-ID mechanism is a more natural foundation for resumable streams than many teams realise.

What breaks in production

A streaming endpoint that works on localhost can fail in ways that are hard to diagnose once it runs behind real infrastructure.

Buffering proxies

The most common failure: events arrive in bursts, or only when the connection closes. Something between Kestrel and the browser is buffering the response, typically a reverse proxy, a CDN, or a compression layer. With nginx, disable buffering for streaming routes:

nginx

location /orders/stream {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_read_timeout 1h;
}

Alternatively, the application can send an X-Accel-Buffering: no response header, which nginx honours for each response. IIS with Application Request Routing, some CDNs and some managed load balancers have their own buffering and idle-timeout settings. Check each hop, and test through the full production path rather than directly against the app.

Idle timeouts

Load balancers and proxies close connections that carry no data for a set time, often 60 seconds or a few minutes. A stream of rare events will be dropped silently. Send a heartbeat, such as a lightweight ping event every 15–30 seconds when the stream is otherwise idle. The client can ignore it, and it keeps every hop's idle timer from expiring.

The HTTP/1.1 connection limit

Browsers limit HTTP/1.1 connections to roughly six per origin. Each open EventSource holds one of them for as long as it lives. A user with the application open in several tabs can exhaust the pool, and ordinary API requests start hanging. Over HTTP/2 the problem mostly disappears, because streams are multiplexed over one connection with a much higher limit, typically negotiated at around 100. Make sure HTTP/2 is enabled end-to-end, and consider sharing a single stream across tabs, for example through a SharedWorker or BroadcastChannel.

Server resources per connection

Every open stream is a long-lived request. Kestrel handles large numbers of concurrent connections well, because the async iterator does not hold a thread while it waits. But anything your iterator holds per connection does multiply: a database connection, a large buffer, a per-client polling loop against a database. The scalable pattern is to fan out from a shared source: one subscription to the event source per server, distributed to per-connection bounded channels. When a slow client's channel fills up, disconnect it and let it resume from Last-Event-ID, instead of letting memory grow without limit.

Authentication for the lifetime of the stream

A stream opened at 09:00 with a valid session is still open at 17:00, even if the session expired or the user's permissions changed at noon. Authorisation is checked when the request starts, not continuously. For sensitive data, close streams when sessions expire, or re-check authorisation periodically inside the iterator.

Generated streaming code needs extra review

Streaming endpoints are a common request to AI coding assistants, especially for AI chat features, and the generated code tends to work in a first demo. The failure patterns are predictable: iterators without cancellation tokens, streams with no event IDs, client code listening to onmessage while the server sends named events, tokens passed in query strings to satisfy EventSource, and no heartbeat. None of these fail in local testing. All of them fail in production, usually under load or behind a proxy. When reviewing generated streaming code, check cancellation, resumption, authentication, proxy behaviour and per-connection resource use explicitly, because the demo won't reveal any of them.

The decision in practice

Start with SSE when the server does most of the talking. Notifications, status feeds, dashboards, job progress and streamed AI output all fit, and in .NET 10 the server side is a single typed result over an async iterator. You get plain HTTP semantics, standard middleware and authentication, visibility in ordinary tooling, and reconnection built into the browser.

Use SignalR when the client talks back often, when you need groups and user targeting across a cluster without building that infrastructure yourself, or when you want Azure SignalR Service to take over connection management.

Both can coexist in one application. The mistake is choosing SignalR by default for problems that are really one-way streams, or choosing SSE and discovering later that you have rebuilt half of SignalR by hand.

A

// author

Alex

More by author →