Problem Context
WebSockets give you a single, persistent, full-duplex TCP connection upgraded from HTTP. They're still the workhorse for chat, live dashboards, presence, collaborative editing, and trading UIs in 2026. The competition has narrowed: SSE for server-only push, WebTransport (HTTP/3, datagram + streams, GA in Chromium and Edge) for low-latency UDP-style messaging, and SignalR on .NET 9 for managed reconnection + transport negotiation. Picking one is mostly about what your firewall, browsers, and operations team can run.
The thing every team gets wrong: scaling. A single instance handles 50-100k concurrent sockets fine. The moment you scale out, you need a backplane (Redis Streams, Azure SignalR Service, NATS) so that a message published on instance A reaches a subscriber on instance B.
- Your dashboard polls
/statusevery 2 seconds - Your chat works locally but breaks behind your reverse proxy
- Half your users are on cellular and the connection drops every 30 seconds
- You scaled to two instances and presence stopped working
SignalR + a Redis or Azure backplane handles 95% of cases without writing your own framing.
Concept Explanation
- Handshake โ HTTP/1.1 GET with
Upgrade: websocket, server replies 101 Switching Protocols. - Frames โ text, binary, ping/pong, close. No HTTP semantics after upgrade.
- Heartbeat โ ping/pong every ~15-30s; idle TCP gets killed by NATs and load balancers.
- Backpressure โ slow consumers can balloon server memory. Bound the send queue or drop.
flowchart LR
C1["Client A"] -->|wss| LB["Load balancer<br/>(sticky / cookie)"]
C2["Client B"] -->|wss| LB
LB --> S1["SignalR instance 1"]
LB --> S2["SignalR instance 2"]
S1 <-->|backplane| BP["Redis Streams<br/>or Azure SignalR Service"]
S2 <--> BP
style BP fill:#DC382D,color:#fff,stroke:#a02a23
Implementation
Step 1: Bare WebSocket in ASP.NET Core 9
var app = WebApplication.CreateBuilder(args).Build();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(15) // ping/pong frequency
});
app.Map("/ws", async (HttpContext ctx) =>
{
if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var buffer = new byte[4 * 1024];
while (ws.State == WebSocketState.Open)
{
var r = await ws.ReceiveAsync(buffer, ctx.RequestAborted);
if (r.MessageType == WebSocketMessageType.Close) break;
var msg = Encoding.UTF8.GetString(buffer, 0, r.Count);
await ws.SendAsync(Encoding.UTF8.GetBytes($"echo: {msg}"),
WebSocketMessageType.Text, true, ctx.RequestAborted);
}
});
app.Run();Step 2: SignalR hub (recommended for production)
builder.Services.AddSignalR()
.AddMessagePackProtocol() // smaller frames than JSON
.AddStackExchangeRedis("redis:6379"); // backplane
app.MapHub<PresenceHub>("/hubs/presence");
public class PresenceHub : Hub
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier!;
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");
await Clients.Others.SendAsync("user-online", userId);
await base.OnConnectedAsync();
}
public Task Send(string toUserId, string body) =>
Clients.Group($"user:{toUserId}").SendAsync("message", new { from = Context.UserIdentifier, body });
}Step 3: Client (TypeScript) with reconnect
// const conn = new HubConnectionBuilder()
// .withUrl("/hubs/presence", { accessTokenFactory: () => getJwt() })
// .withAutomaticReconnect([0, 2_000, 5_000, 10_000, 30_000])
// .withHubProtocol(new MessagePackHubProtocol())
// .configureLogging(LogLevel.Information)
// .build();
//
// conn.on("message", (m) => render(m));
// conn.onreconnecting(() => showBanner("reconnecting..."));
// conn.onreconnected(() => hideBanner());
// await conn.start();
// SignalR negotiates: WebSockets โ SSE โ long-polling. Works behind any proxy.Step 4: Auth on the upgrade request
// JWT in query string is the only way browsers can send creds on WS upgrade
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var token = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
ctx.Token = token;
return Task.CompletedTask;
}
};
});
[Authorize] // standard ASP.NET auth on hubs
public class PresenceHub : Hub { ... }Step 5: Backpressure + slow consumer handling
builder.Services.AddSignalR(o =>
{
o.MaximumReceiveMessageSize = 32 * 1024; // reject huge frames
o.StreamBufferCapacity = 10; // drop after 10 buffered
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
o.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
// For raw WS: track per-connection send queue depth and disconnect if it
// exceeds, say, 200 messages. One slow client must not OOM the server.Step 6: Scale out with Azure SignalR Service or Redis
// Option A โ Redis backplane (DIY, cheaper)
builder.Services.AddSignalR().AddStackExchangeRedis("redis:6379");
// Option B โ Azure SignalR Service (managed, no sticky sessions needed)
builder.Services.AddSignalR().AddAzureSignalR(opts =>
{
opts.ConnectionString = builder.Configuration["AzureSignalR"];
});
// Service offloads the sockets; your app becomes stateless.
// Required for serverless (Azure Functions) hubs.Common Pitfalls
- No heartbeat.Cellular NATs and L4 LBs kill idle TCP after 30-120 seconds. Without ping/pong, your "working" socket has actually been closed for 90 seconds.
- Scaling out without a backplane. Two instances = two islands. A user connected to instance A never sees a message published on instance B.
- No backpressure. One slow client + a chatty broadcast and your process RAM grows linearly with time. Bound the send buffer; disconnect slow consumers.
- Auth in headers, not query. Browser WebSocket APIs can't set
Authorization:headers on the upgrade. Use a query-string token (and treat the URL as a credential โ log carefully). - Sending huge frames. A 5MB JSON frame freezes the event loop on both ends. Chunk over multiple messages or send a link to a blob.
- Trusting client sequence numbers for ordering. Clients reconnect, retry, send out of order. Add server-assigned monotonic IDs and dedupe on receive.
Practical Takeaways
- SignalR is the right default on .NET. It manages negotiation, reconnect, framing, and groups.
- Always heartbeat (ping/pong) every 15-30 seconds.
- Always run a backplane the moment you have more than one instance.
- SSE for one-way push is simpler and works through more proxies than WebSockets.
- WebTransport (HTTP/3) is the future for low-latency multi-stream apps; usable today in Chromium/Edge.
- Bound the per-connection send queue; one slow client must not take the server down.
- Treat the WS upgrade URL as auth-bearing โ never log it in plaintext.

