WebSockets in .NET: Raw Real-Time vs SignalR
Build WebSocket APIs in ASP.NET Core. Covers raw WebSocket protocol, WebSocket middleware, message framing, connection management, when to use raw WebSockets vs SignalR, and production patterns.
WebSockets vs SignalR vs SSE
| | Raw WebSocket | SignalR | Server-Sent Events | |---|---|---|---| | Direction | Bidirectional | Bidirectional | Server → Client only | | Protocol | WS/WSS | WS → Long-poll fallback | HTTP | | Abstraction | None | High (hubs, groups, reconnect) | Low | | Overhead | Minimal | Some | Minimal | | Use when | Custom protocol, max control | Hub-and-spoke, groups | Server push only |
Use raw WebSockets when:
- You're building a custom binary protocol
- You need maximum performance with minimal overhead
- You're interfacing with an existing WS spec (financial feeds, gaming protocols)
- SignalR's abstraction doesn't fit (peer-to-peer, mesh topology)
Use SignalR when:
- You need groups, user targeting, automatic reconnect
- Clients include browsers, mobile apps, other .NET services
- You want the fallback to long-polling for clients that can't do WebSocket
Enable WebSockets
// Program.cs
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
// WebSocket endpoint
app.MapGet("/ws/orders", async (HttpContext ctx, CancellationToken ct) =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
await HandleConnectionAsync(ws, ct);
});Basic Echo Server
private static async Task HandleConnectionAsync(WebSocket ws, CancellationToken ct)
{
var buffer = new byte[4096];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", ct);
break;
}
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
var response = Encoding.UTF8.GetBytes($"Echo: {message}");
await ws.SendAsync(
new ArraySegment<byte>(response),
WebSocketMessageType.Text,
endOfMessage: true,
ct);
}
}
}Connection Manager
Track and broadcast to multiple clients:
// Services/WebSocketConnectionManager.cs
public class WebSocketConnectionManager
{
private readonly ConcurrentDictionary<string, WebSocket> _connections = new();
public string AddConnection(WebSocket socket)
{
var id = Guid.NewGuid().ToString();
_connections[id] = socket;
return id;
}
public void RemoveConnection(string id) => _connections.TryRemove(id, out _);
public async Task BroadcastAsync(string message, CancellationToken ct)
{
var data = Encoding.UTF8.GetBytes(message);
var segment = new ArraySegment<byte>(data);
var dead = new List<string>();
foreach (var (id, socket) in _connections)
{
if (socket.State != WebSocketState.Open) { dead.Add(id); continue; }
try
{
await socket.SendAsync(segment, WebSocketMessageType.Text, true, ct);
}
catch
{
dead.Add(id);
}
}
foreach (var id in dead) RemoveConnection(id);
}
public async Task SendToAsync(string connectionId, string message, CancellationToken ct)
{
if (!_connections.TryGetValue(connectionId, out var socket)) return;
if (socket.State != WebSocketState.Open) return;
var data = Encoding.UTF8.GetBytes(message);
await socket.SendAsync(new ArraySegment<byte>(data), WebSocketMessageType.Text, true, ct);
}
}Real-Time Order Feed
// Register
builder.Services.AddSingleton<WebSocketConnectionManager>();
builder.Services.AddSingleton<OrderFeedService>();
// Endpoint
app.MapGet("/ws/orders/feed", async (
HttpContext ctx,
WebSocketConnectionManager manager,
CancellationToken ct) =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = 400;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var connectionId = manager.AddConnection(ws);
try
{
await ReceiveLoopAsync(ws, ct); // keep connection alive
}
finally
{
manager.RemoveConnection(connectionId);
}
});
// OrderFeedService — publishes to all connected clients
public class OrderFeedService
{
private readonly WebSocketConnectionManager _manager;
public OrderFeedService(WebSocketConnectionManager manager) => _manager = manager;
public async Task NotifyOrderCreatedAsync(OrderCreatedEvent @event, CancellationToken ct)
{
var payload = JsonSerializer.Serialize(new
{
type = "ORDER_CREATED",
orderId = @event.OrderId,
customer = @event.CustomerId,
total = @event.Total,
timestamp = DateTime.UtcNow
});
await _manager.BroadcastAsync(payload, ct);
}
}Framing Large Messages
WebSocket frames have a maximum size. For large payloads, receive all fragments:
private static async Task<string> ReceiveFullMessageAsync(
WebSocket ws, CancellationToken ct)
{
using var ms = new MemoryStream();
var buffer = new byte[4096];
WebSocketReceiveResult result;
do
{
result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
if (result.MessageType == WebSocketMessageType.Close)
return string.Empty;
ms.Write(buffer, 0, result.Count);
}
while (!result.EndOfMessage);
return Encoding.UTF8.GetString(ms.ToArray());
}Authentication
WebSockets don't support Authorization headers after the handshake. Pass the token as a query parameter during the upgrade:
app.MapGet("/ws/secure", async (HttpContext ctx, CancellationToken ct) =>
{
if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; }
// Extract token from query string (set during WS handshake)
var token = ctx.Request.Query["access_token"].FirstOrDefault();
if (string.IsNullOrEmpty(token)) { ctx.Response.StatusCode = 401; return; }
var principal = ValidateToken(token);
if (principal is null) { ctx.Response.StatusCode = 401; return; }
ctx.User = principal;
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
await HandleAuthenticatedConnectionAsync(ws, principal, ct);
});SignalR handles this automatically via its auth middleware integration.
Client-Side (JavaScript)
const ws = new WebSocket('wss://api.orderflow.com/ws/orders/feed?access_token=...');
ws.onopen = () => console.log('Connected');
ws.onclose = () => setTimeout(connect, 3000); // auto-reconnect
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'ORDER_CREATED') {
addOrderToTable(data);
}
};
ws.onerror = (error) => console.error('WebSocket error', error);
// Send message
ws.send(JSON.stringify({ type: 'SUBSCRIBE', topic: 'orders' }));Production Considerations
Scaling: WebSocket connections are stateful and long-lived. Use sticky sessions (load balancer routes same user to same server) or a pub/sub backplane (Redis) to broadcast across instances.
Health: Track open connection count as a metric. Alert on sudden drops (server crash, network event).
Timeouts: Configure KeepAliveInterval and close idle connections that haven't sent any message in N minutes.
Back-pressure: If a slow client can't consume messages fast enough, buffer them — but set a max buffer size and drop or close when exceeded.
Interview Questions
Q: What is the difference between WebSockets and Server-Sent Events? WebSockets are bidirectional — both client and server can send messages. SSE is server-to-client only — the server pushes events over a persistent HTTP connection. SSE is simpler, works over HTTP/2, and auto-reconnects. Use SSE for server push (notifications, live feeds); use WebSockets for interactive two-way communication.
Q: When would you choose raw WebSockets over SignalR? When you need full control over the protocol (binary frames, custom framing, existing WS spec compliance), maximum performance without SignalR overhead, or a network topology that doesn't fit SignalR's hub model (peer-to-peer, mesh). For typical hub-and-spoke patterns with groups, use SignalR.
Q: How do you authenticate a WebSocket connection? At connection time (before the upgrade). Pass a short-lived token in the query string or as a subprotocol during the WebSocket handshake. The server validates the token before accepting the connection. Passing credentials after connection establishment is non-standard.
Q: How do you scale WebSocket servers horizontally? WebSocket connections are stateful — a client connected to Server A can't receive messages sent to Server B. Solutions: sticky sessions (route by connection ID), or a pub/sub backplane (Redis) where servers subscribe to channels and broadcast to their local connections when messages arrive.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.