Skip to main content

ASP.NET Core: WebSockets

Published 5 min readMarkdown

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

  1. Client Request: Sends headers Connection: Upgrade and Upgrade: websocket.

  2. Server Check: Verifies it's a valid WebSocket request.

  3. 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.

  1. Server: Starts a background Task.Delay loop.

  2. Action: Every 30 seconds, send a specific byte (e.g., empty byte array or string "PING").

  3. Check: If the SendAsync throws 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:

  1. User 1 sends msg: Server A receives it.

  2. Publish: Server A pushes the msg to Redis Pub/Sub on channel "GlobalChat".

  3. Subscribe: Server B (listening to Redis) gets the message.

  4. Forward: Server B looks up User 2 in its local RAM and sends the message via WebSocket.


Summary

  1. Upgrading: HTTP 101 switch. Controller pipeline pauses/hangs until socket closes.

  2. Buffers: Always use ArrayPool or Memory<T> to avoid GC storms.

  3. Framing: A message is a stream of frames. Check .EndOfMessage.

  4. Concurrency: Never SendAsync concurrently on the same socket. Lock it.

  5. Zombies: The socket won't close if the cable is pulled. You must Ping.

No comments yet. Be the first to comment.

More from this blog