SSE, WebSockets, SignalR or Polling
Choose between polling, long polling, SSE, WebSockets and SignalR for notifications, dashboards and chat, and keep it working behind proxies.
GET /orders/2042/status + If-None-Match -> 304
GET /orders/2042/status/next?after=3 -> waits, 200 or 204
TypedResults.ServerSentEvents(stream)
app.MapHub<OrdersHub>("/hubs/orders")Every option answers the same question: how does a client learn that something changed on the server? They differ in direction, in what the network path must allow, and in what each connected client costs.
The options
| Option | Direction | Held open | Several instances |
|---|---|---|---|
| Polling with ETag | Client pulls | Nothing | Stateless, any instance |
| Long polling | Pull, server waits for a change | One pending request | Needs a shared change signal |
| SSE | Server to client | One HTTP response | Resume on any instance if events are stored |
| WebSockets | Both | One upgraded connection | You build fan-out |
| SignalR | Both, RPC, groups | WebSocket or fallback | Sticky sessions plus a backplane |
Choosing
- Dashboards refreshed every 10 to 30 seconds: polling with
ETagandIf-None-Match. A 304 costs almost nothing, works through every proxy and cache, and survives deploys untouched. It is the right answer more often than teams expect. - Notifications and progress (order shipped, export 80% done): SSE. One direction is all you need, it is plain HTTP, and
EventSourcereconnects and resumes withLast-Event-ID. It cannot send anAuthorizationheader, so use cookie auth for browser streams; a token in the query string ends up in proxy and access logs (see net-signalr-hubs). - Chat, collaboration, live auctions, where clients send often too: SignalR. Groups, users, reconnects and typed clients are already written.
- Raw WebSockets when you must speak an existing protocol or need binary frames without the hub layer; you then own framing, pings, reconnects and fan-out.
- Service to service: none of these. Use gRPC streaming, a message broker or signed webhooks. Mobile apps in the background hold no connection at all: use the platform push service.
Long polling is the fallback when SSE and WebSockets are blocked; few teams should choose it first.
What the network path does
- Buffering. nginx buffers proxied responses, so SSE needs
X-Accel-Buffering: noorproxy_buffering off(details in net-server-sent-events). - Idle timeouts. Proxies close silent connections, commonly after 60 seconds: send heartbeats and keep long-poll waits shorter (SignalR's numbers are in net-signalr-scale-out).
- Upgrade headers. WebSockets need
UpgradeandConnectionforwarded; proxies that strip them push SignalR down to SSE or long polling without an error. - Six connections. Browsers allow 6 HTTP/1.1 connections per origin and each stream holds one; serve streams over HTTP/2.
- Deploys. Open connections hold shutdown until they end (net-graceful-shutdown), and clients reconnect together unless they add jitter (net-signalr-scale-out).
Example
When to use it
- Refresh a warehouse dashboard every 15 seconds with ETag polling so that unchanged data costs a 304 and no open connection
- Notify a customer's browser that an invoice PDF has finished generating, using SSE behind nginx with buffering turned off
- Run a support chat between agents and customers on SignalR, where both sides send messages and agents join several rooms
More examples
A raw WebSocket endpoint and what it leaves to you
The request stays open for the life of the socket, so the handler must keep reading: a close frame only arrives through <code>ReceiveAsync</code>, which is why a separate loop waits for it while the timer loop sends, re-checking after each wait and treating <code>WebSocketException</code> as a normal disconnect. WebSockets are not covered by CORS, so with cookie auth list your sites in <code>AllowedOrigins</code> or any page can open the socket as the user. <code>KeepAliveInterval</code> sends protocol pings that keep proxies from timing the connection out. Everything else (message framing beyond one JSON object, auth refresh, reconnects, groups, fan-out across instances) is yours to build, which is what SignalR exists to avoid.
using System.Net.WebSockets;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StockLevels>();
var app = builder.Build();
// WebSockets bypass CORS: with cookie auth, an unchecked Origin lets any site open this socket.
var webSockets = new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(20) };
webSockets.AllowedOrigins.Add("https://orders.example.com");
app.UseWebSockets(webSockets);
app.Map("/ws/stock/{sku}", async (string sku, HttpContext context, StockLevels stock) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var socket = await context.WebSockets.AcceptWebSocketAsync();
var ct = context.RequestAborted;
var closed = WaitForCloseAsync(socket, ct);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
try
{
// Check after the wait: the client may have closed during it, and sending then would throw.
while (await timer.WaitForNextTickAsync(ct) && !closed.IsCompleted)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(new { sku, available = stock.Available(sku), at = DateTimeOffset.UtcNow });
await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, ct);
}
await closed;
}
catch (Exception ex) when (ex is WebSocketException or OperationCanceledException)
{
// The client vanished without a close frame or closed mid-send: an ordinary end, not an error.
}
});
app.Run();
static async Task WaitForCloseAsync(WebSocket socket, CancellationToken ct)
{
var buffer = new byte[1024];
while (true)
{
var result = await socket.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "closing", ct);
return;
}
}
}
public sealed class StockLevels
{
private readonly Dictionary<string, int> levels = new() { ["KETTLE-BLUE-1L"] = 140, ["MUG-WHITE-350"] = 820 };
public int Available(string sku) => levels.GetValueOrDefault(sku);
}nginx in front of SSE and WebSocket routes
<code>proxy_buffering off</code> is the server-side twin of the <code>X-Accel-Buffering: no</code> response header; without either, nginx collects events and the browser sees nothing until a buffer fills. <code>proxy_read_timeout</code> defaults to 60 seconds of silence, so heartbeats must be more frequent than the value you set. <code>http2 on</code> lets browsers open many SSE streams over one connection. The <code>map</code> block sends <code>Connection: upgrade</code> only for real upgrade requests.
# In the http block: send "Connection: upgrade" only when the client asked for an upgrade.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream orders_api {
server orders-api:8080;
keepalive 32;
}
server {
listen 443 ssl;
http2 on;
server_name orders.example.com;
# Server-Sent Events: pass each event through as soon as it is written.
location /orders/ {
proxy_pass http://orders_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 120s;
}
# SignalR and raw WebSockets: forward the upgrade handshake.
location /hubs/ {
proxy_pass http://orders_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 120s;
}
}SSE with heartbeats, bounded buffers and fast shutdown
Two authorization rules first. The stream requires the auth cookie and takes the customer id from its claim, not the route, so changing an id in the URL cannot stream someone else's orders. The publish endpoint only answers on internal port 8081, which the ingress never exposes, so the internet cannot forge notifications; a service-to-service token policy is the alternative. Then three production details in one stream. A heartbeat event every 20 seconds keeps proxies with a 60-second idle timeout from cutting a quiet stream. Each subscriber gets a bounded channel with <code>DropOldest</code>, so one stalled browser cannot grow server memory. Linking <code>ApplicationStopping</code> ends every stream as soon as the host shuts down, instead of holding the rolling deploy for the whole <code>ShutdownTimeout</code>. Like SignalR without a backplane, this only reaches clients on the instance that received the POST.
using System.Collections.Concurrent;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.AspNetCore.Authentication.Cookies;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ListenAnyIP(8080); // public, behind the ingress
kestrel.ListenAnyIP(8081); // cluster-internal only, never exposed by the ingress
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
builder.Services.AddAuthorization(options =>
// The local port, not the spoofable Host header that RequireHost matches.
options.AddPolicy("internal", policy => policy.RequireAssertion(context =>
context.Resource is HttpContext http && http.Connection.LocalPort == 8081)));
builder.Services.AddSingleton<OrderNotifications>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// The customer comes from the auth cookie, never from the URL, so nobody can watch another customer's orders.
app.MapGet("/me/notifications",
(ClaimsPrincipal user, HttpContext http, OrderNotifications notifications, IHostApplicationLifetime lifetime) =>
{
if (!int.TryParse(user.FindFirstValue("customer_id"), out var customerId)) return Results.Forbid();
http.Response.Headers["X-Accel-Buffering"] = "no";
// End streams when the host stops, or shutdown waits for them until ShutdownTimeout.
var stop = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted, lifetime.ApplicationStopping);
http.Response.RegisterForDispose(stop);
var reader = notifications.Subscribe(customerId, stop.Token);
return TypedResults.ServerSentEvents(StreamAsync(reader, TimeSpan.FromSeconds(20), stop.Token));
}).RequireAuthorization();
app.MapPost("/internal/notifications", (OrderNotification notification, OrderNotifications notifications) =>
{
notifications.Publish(notification);
return TypedResults.Accepted((string?)null);
}).RequireAuthorization("internal");
app.Run();
static async IAsyncEnumerable<SseItem<string>> StreamAsync(
ChannelReader<OrderNotification> reader, TimeSpan heartbeat, [EnumeratorCancellation] CancellationToken ct)
{
while (true)
{
var ready = await WaitAsync(reader, heartbeat, ct);
if (ready is null)
{
yield return new SseItem<string>("{}", "heartbeat"); // keeps idle-timeout proxies from closing the stream
continue;
}
if (ready == false) yield break;
while (reader.TryRead(out var notification))
{
yield return new SseItem<string>(JsonSerializer.Serialize(notification), "order");
}
}
}
static async Task<bool?> WaitAsync(ChannelReader<OrderNotification> reader, TimeSpan heartbeat, CancellationToken ct)
{
using var limit = CancellationTokenSource.CreateLinkedTokenSource(ct);
limit.CancelAfter(heartbeat);
try
{
return await reader.WaitToReadAsync(limit.Token);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return null;
}
}
public sealed record OrderNotification(int CustomerId, int OrderId, string Status);
public sealed class OrderNotifications
{
private readonly ConcurrentDictionary<Guid, (int CustomerId, Channel<OrderNotification> Channel)> subscribers = new();
public ChannelReader<OrderNotification> Subscribe(int customerId, CancellationToken ct)
{
var id = Guid.NewGuid();
// A slow client loses old notifications instead of growing memory without limit.
var channel = Channel.CreateBounded<OrderNotification>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest
});
subscribers[id] = (customerId, channel);
ct.Register(() =>
{
subscribers.TryRemove(id, out _);
channel.Writer.TryComplete();
});
return channel.Reader;
}
public void Publish(OrderNotification notification)
{
foreach (var (customerId, channel) in subscribers.Values)
{
if (customerId == notification.CustomerId) channel.Writer.TryWrite(notification);
}
}
}
Discussion