ASP.NET Core: WebSockets
covers the implementation of Raw WebSockets in ASP.NET 9, bypassing high-level libraries like SignalR to interact directly with the System.Net.WebSockets API.
This approach is required for high-frequency trading, gaming servers, or IoT devices where every byte of overhead matters.
1. The Protocol Upgrade (The Handshake)
A WebSocket connection starts its life as a standard HTTP GET request. The client asks the server to "Upgrade" the connection from HTTP (stateless) to WebSocket (stateful/persistent).
The Mechanics
Client Request: Sends headers
Connection: UpgradeandUpgrade: websocket.Server Check: Verifies it's a valid WebSocket request.
Protocol Switch: Server returns
101 Switching Protocols. The TCP connection remains open, but HTTP rules no longer apply. It is now a raw binary pipe.
The Code (Controller Implementation)
[Route("ws")]
public class WebSocketController : ControllerBase
{
[HttpGet]
public async Task Get()
{
// 1. Validation: Is this actually a socket request?
if (HttpContext.WebSockets.IsWebSocketRequest)
{
// 2. Accept the handshake.
// This sends the "101 Switching Protocols" response immediately.
using WebSocket webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
// 3. Enter the "Game Loop"
// The request pipeline is PAUSED here. The controller action does not return
// until the socket is closed.
await EchoService.Handle(webSocket);
}
else
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
}
}
}
2. Memory Management (The "Buffer")
This is the most critical micro-concept. TCP streams do not send "messages"; they send a stream of bytes. You must define a Buffer (a bucket) to catch these bytes as they arrive.
Why ArrayPool?
Creating a new byte[4096] for every message creates massive GC pressure. Instead, we rent a reusable bucket from the system ArrayPool.
// Rent a bucket of 4KB
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 4);
try
{
// Use the buffer...
}
finally
{
// MUST return it, or the pool runs dry (memory leak).
ArrayPool<byte>.Shared.Return(buffer);
}
3. The Receive Loop & Fragmentation
WebSocket messages can be larger than your buffer. A 10KB JSON message will arrive in your 4KB buffer in chunks (fragments).
The Micro-Concept: You must check result.EndOfMessage.
If
false: You have a partial message. Store it and listen again.If
true: You have the complete message (or the final chunk).
Complete Optimized Loop Code
public static async Task Handle(WebSocket webSocket)
{
// 1. Rent a buffer (Zero Allocation strategy)
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 4);
try
{
// 2. The Receive Loop
// We stay in this loop for the lifetime of the connection (hours/days).
while (webSocket.State == WebSocketState.Open)
{
// RECEIVE
// We write directly into the rented memory.
var result = await webSocket.ReceiveAsync(
new Memory<byte>(buffer), CancellationToken.None);
// 3. Handle specific message types
if (result.MessageType == WebSocketMessageType.Close)
{
await webSocket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closing",
CancellationToken.None);
break; // Exit loop
}
// 4. Handle Fragmentation (Simulated for simplicity)
// In a real scenario, if !result.EndOfMessage, you would resize/copy
// to a larger list. Here we assume messages fit in 4KB for clarity.
// PROCESS
// Create a "span" (window) over only the bytes we actually received.
// If buffer is 4096 bytes but we got 50 bytes, we only look at first 50.
var messageBytes = buffer.AsSpan(0, result.Count);
// Example: Echo it back
await webSocket.SendAsync(
new ReadOnlyMemory<byte>(buffer, 0, result.Count), // Send exact slice
result.MessageType, // Text or Binary
result.EndOfMessage, // Mark as complete
CancellationToken.None);
}
}
catch (WebSocketException)
{
// Client disconnected abruptly (e.g. pulled plug)
}
finally
{
// 5. CRITICAL: Return memory to pool
ArrayPool<byte>.Shared.Return(buffer);
}
}
4. Fragmentation Deep Dive (Stitching Messages)
If you expect large payloads (e.g., uploading images via socket), you cannot rely on a single ReceiveAsync call. You need a growing list.
// List to hold chunks if message > 4KB
var messageParts = new List<byte>();
do
{
result = await webSocket.ReceiveAsync(new Memory<byte>(buffer), ct);
// Copy current chunk to our "Growing List"
messageParts.AddRange(buffer.Take(result.Count));
} while (!result.EndOfMessage); // Keep reading until EndOfMessage is true
// Now 'messageParts' has the full JSON/Image
var fullMessage = Encoding.UTF8.GetString(messageParts.ToArray());
Note: For extreme performance, System.IO.Pipelines is preferred over List<byte> to avoid copying, but that is an even deeper topic.
5. Detecting Zombie Connections (Heartbeats)
The Problem: If a user rips out their ethernet cable, the TCP connection doesn't close immediately. It enters a "Half-Open" state. Your server thinks the user is there, but they are gone.
The Raw Solution: Raw WebSockets do not have built-in Pings (unlike SignalR). You must build it.
Server: Starts a background
Task.Delayloop.Action: Every 30 seconds, send a specific byte (e.g., empty byte array or string "PING").
Check: If the
SendAsyncthrows an exception, the socket is dead. Close it.
// Run this alongside the Receive Loop
_ = Task.Run(async () =>
{
while (webSocket.State == WebSocketState.Open)
{
await Task.Delay(TimeSpan.FromSeconds(30));
try
{
// Send empty PING packet
await webSocket.SendAsync(
new ReadOnlyMemory<byte>(new byte[0]),
WebSocketMessageType.Binary,
true,
CancellationToken.None);
}
catch
{
break; // Socket is dead
}
}
});
6. Threading & Concurrency (The Trap)
Crucial Rule: The WebSocket class is NOT thread-safe. You cannot have one thread calling SendAsync while another thread is also calling SendAsync on the same socket object simultaneously. It will corrupt the stream.
The Solution: Use a SemaphoreSlim or Channel<T> to queue outgoing messages so they are sent one by one.
// Thread-Safe Sending Wrapper
public class SocketWrapper
{
private readonly WebSocket _socket;
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task SendSafeAsync(byte[] data)
{
await _gate.WaitAsync(); // Only 1 thread enters here
try
{
await _socket.SendAsync(..., CancellationToken.None);
}
finally
{
_gate.Release();
}
}
}
7. Scaling Out (Redis Backplane)
If you have 2 servers (Server A, Server B):
User 1 connects to Server A.
User 2 connects to Server B.
If User 1 wants to chat with User 2, Server A cannot access User 2's socket directly. Sockets are held in RAM.
The Architecture:
User 1 sends msg: Server A receives it.
Publish: Server A pushes the msg to Redis Pub/Sub on channel "GlobalChat".
Subscribe: Server B (listening to Redis) gets the message.
Forward: Server B looks up User 2 in its local RAM and sends the message via WebSocket.
Summary
Upgrading: HTTP 101 switch. Controller pipeline pauses/hangs until socket closes.
Buffers: Always use
ArrayPoolorMemory<T>to avoid GC storms.Framing: A message is a stream of frames. Check
.EndOfMessage.Concurrency: Never
SendAsyncconcurrently on the same socket. Lock it.Zombies: The socket won't close if the cable is pulled. You must Ping.
Comments
Join the discussionNo comments yet. Be the first to comment.