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.

๐Ÿค” Sound familiar?
  • Your dashboard polls /status every 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

  1. 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.
  2. Scaling out without a backplane. Two instances = two islands. A user connected to instance A never sees a message published on instance B.
  3. No backpressure. One slow client + a chatty broadcast and your process RAM grows linearly with time. Bound the send buffer; disconnect slow consumers.
  4. 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).
  5. 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.
  6. 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.