Mert Özen Further With Every Line
Backend Real-time Applications — #02

Raw WebSockets in ASP.NET Core: UseWebSockets, the Receive Loop, the Close Handshake and an Angular Client

Mert Özen Nov 8, 2026 13 min 3 views
Raw WebSockets in ASP.NET Core: UseWebSockets, the Receive Loop, the Close Handshake and an Angular Client

With raw WebSockets you must accumulate a message until EndOfMessage with a size limit, write to the socket from a single loop and check Origin yourself. The receive loop, the close handshake, KeepAliveTimeout, a Channel-based send queue, a connection registry, the compression risk, and reconnecting with backoff in Angular using RxJS webSocket().

Version note: The code in this post was built and run with .NET 10, Angular 22.2 and RxJS 7.8.

Guess First

Two questions. First: the browser sends a single 2,997-byte JSON (JavaScript Object Notation) message, and on the server you call ReceiveAsync with a 1,024-byte buffer. How many times does ReceiveAsync return before you have the whole message? Second: what happens if 20 different threads call SendAsync on the same WebSocket object at the same time? Does it throw, do the messages go out in order, or something else?

The answer to the second question is the most important lesson of this post, and it surprised me: there is no exception on the server, but the client drops the connection with a protocol error.

UseWebSockets and Accepting the Connection

In the previous post we saw raw WebSocket as one of five options. In this post we bring it close to production: clients in Bookstore watching order status, a connection registry, and broadcasting from anywhere on the server.

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ConnectionRegistry>();

WebApplication app = builder.Build();

WebSocketOptions webSocketOptions = new()
{
    KeepAliveInterval = TimeSpan.FromSeconds(30),
    KeepAliveTimeout = TimeSpan.FromSeconds(10)
};
webSocketOptions.AllowedOrigins.Add("http://localhost:4200");
app.UseWebSockets(webSocketOptions);

app.Map("/ws/orders", async (HttpContext context, ConnectionRegistry registry, ILogger<WebSocketConnection> logger) =>
{
    if (!context.WebSockets.IsWebSocketRequest)
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        return;
    }

    using System.Net.WebSockets.WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
    WebSocketConnection connection = new(socket, logger);
    registry.Add(connection);
    try
    {
        await connection.RunAsync(context.RequestAborted);
    }
    finally
    {
        registry.Remove(connection);
    }
});

The UseWebSockets middleware (part of the pipeline from the Middleware post of the ASP.NET Core Web API (Application Programming Interface) series) recognises the Upgrade: websocket request and populates context.WebSockets. AcceptWebSocketAsync sends the 101 response, and you now hold a WebSocket object wrapping a TCP (Transmission Control Protocol) connection.

One critical rule: the endpoint delegate must not finish while the connection is open. If you return without awaiting RunAsync, the pipeline ends, the response completes, and every later SendAsync blows up with "Cannot write to the response body, the response has completed". You cannot stash the socket somewhere and "use it later"; the lifetime of the request is the lifetime of the connection.

There is a reason I used app.Map instead of app.MapGet: since .NET 7, Kestrel also accepts WebSockets over HTTP (Hypertext Transfer Protocol) version 2, i.e. HTTP/2, and there the handshake is not a GET but the extended CONNECT request from RFC (Request for Comments) 8441. If you write MapGet, a CONNECT request arriving over HTTP/2 does not match the endpoint. Chrome, Edge and Firefox 128+ use HTTP/2 WebSockets by default.

As for the options: KeepAliveInterval is how often the server sends a ping frame (default 2 minutes), and KeepAliveTimeout, added in .NET 9, is how long to wait for the pong before the connection is aborted (default Timeout.InfiniteTimeSpan, i.e. disabled). Unless you set both, the connection of a client whose cable was pulled stays open on the server until the TCP layer notices.

Origin check

The browser does not send a CORS (Cross-Origin Resource Sharing) preflight for a WebSocket request and ignores Access-Control-* headers. If a malicious site opens a WebSocket to your server from the user's browser, cookies go along too; this is called CSWSH (Cross-Site WebSocket Hijacking). The only defence is the Origin header. With the AllowedOrigins list filled in:

$ curl -s -i http://localhost:17602/ws/orders -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Origin: http://localhost:4200"
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Date: Sun, 08 Nov 2026 02:08:10 GMT
Server: Kestrel
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

$ curl -s -i http://localhost:17602/ws/orders -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Origin: https://evil.example"
HTTP/1.1 403 Forbidden
Content-Length: 0
Date: Sun, 08 Nov 2026 02:08:12 GMT
Server: Kestrel

When I sent the same request without an Origin header, I got 101. This check only restricts browsers; curl or a script writes whatever header it likes. So the Origin check does not replace authentication, it sits next to it.

The Receive Loop: Fragmented Messages and a Size Limit

private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
{
    byte[] buffer = ArrayPool<byte>.Shared.Rent(1024);
    ArrayBufferWriter<byte> message = new();
    int reads = 0;
    try
    {
        while (true)
        {
            ValueWebSocketReceiveResult result = await socket.ReceiveAsync(buffer.AsMemory(0, 1024), cancellationToken);
            reads++;

            if (result.MessageType == WebSocketMessageType.Close)
            {
                logger.LogInformation("Client {Id} closed: {Status} {Description}",
                    Id, socket.CloseStatus, socket.CloseStatusDescription);
                await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", cancellationToken);
                return;
            }

            if (message.WrittenCount + result.Count > MaxMessageBytes)
            {
                logger.LogWarning("Client {Id} sent more than {Max} bytes, closing", Id, MaxMessageBytes);
                await socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, $"max {MaxMessageBytes} bytes", cancellationToken);
                return;
            }

            message.Write(buffer.AsSpan(0, result.Count));
            if (!result.EndOfMessage)
            {
                continue;
            }

            logger.LogInformation("Client {Id} sent {Bytes} bytes in {Reads} reads", Id, message.WrittenCount, reads);
            Handle(message.WrittenSpan);
            message.ResetWrittenCount();
            reads = 0;
        }
    }
    catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
    {
        logger.LogWarning("Client {Id} disconnected without a close handshake", Id);
    }
    catch (OperationCanceledException)
    {
        logger.LogWarning("Client {Id} connection aborted", Id);
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

From a Node 24 client I sent first a 33-byte subscribe message and then a 2,997-byte message. The server log:

info: Bookstore.Realtime.WebSocketConnection[0]
      Client 471a2573-33ac-4e3a-ad48-aa8f939b6722 sent 33 bytes in 1 reads
info: Bookstore.Realtime.WebSocketConnection[0]
      Client 471a2573-33ac-4e3a-ad48-aa8f939b6722 sent 2997 bytes in 3 reads

The answer to the first opening question: three times. The client sent this message in one piece, but ReceiveAsync does not hand you a frame, it hands you whatever fits in the buffer; on the first two calls EndOfMessage is false. So EndOfMessage means "the message is complete", not "the frame is complete". Try to parse JSON before the message is complete and you get half a JSON document; you will see plenty of sample code doing a single ReceiveAsync with a 4 KB buffer, and because it was tested with small messages the bug hides until production.

Accumulating has a price: accumulate without a limit and a client can send you 1 GB and exhaust your memory. That is why we count the total size and close with MessageTooBig (1009). What a client sending 5,000 bytes sees:

big close: 1009 max 4096 bytes

Returning the buffer rented from ArrayPool in finally matters too; with thousands of open connections, allocating a new array for each one is needless pressure on the GC (garbage collector).

The Close Handshake

The polite way to close a WebSocket is a two-way handshake: one side sends a Close frame, the other answers with its own Close frame, then TCP closes. In the loop above, when the client closes we answer with CloseOutputAsync. When the client calls ws.close(1000, "page closed"), the output on both sides:

info: Bookstore.Realtime.WebSocketConnection[0]
      Client 471a2573-33ac-4e3a-ad48-aa8f939b6722 closed: NormalClosure page closed

close: 1000 bye

The difference between CloseAsync and CloseOutputAsync: CloseAsync sends its own Close frame and waits for the other side's reply; CloseOutputAsync only sends. If the other side has already closed, waiting for a reply is pointless, hence CloseOutputAsync here. When we start the close (message too big), we use CloseAsync.

In real life most connections do not close politely: a laptop lid shuts, a phone enters a tunnel, a process dies. When I killed the Node client with process.exit(), the server saw:

warn: Bookstore.Realtime.WebSocketConnection[0]
      Client cc669afc-89ba-4300-86ee-a5410d339770 disconnected without a close handshake

Here at least the TCP connection was closed and the operating system told the server. When a cable is pulled, not even that notification arrives; that is exactly why KeepAliveTimeout exists.

The Single Writer Rule: A Send Queue with Channel

.NET's WebSocket documentation is clear: at most one send and one receive at a time on the same object. But I wanted to see what happens when you break the rule. I ran this code; 20 threads each send a two-part message to the same socket:

Task[] tasks = Enumerable.Range(0, 20).Select(i => Task.Run(async () =>
{
    byte[] part = Encoding.UTF8.GetBytes($"[{i}-a]" + new string('x', 2_000_000));
    await socket.SendAsync(part, WebSocketMessageType.Text, endOfMessage: false, CancellationToken.None);
    await socket.SendAsync(Encoding.UTF8.GetBytes($"[{i}-b]"), WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None);
})).ToArray();
await Task.WhenAll(tasks);

On the server, Task.WhenAll threw no exception. The .NET client, however, said this:

after 31 receives: WebSocketException: The WebSocket expected a continuation frame after having received a previous non-final frame.

Node 24's built-in WebSocket also closed with code 1006 without receiving a single message. The reason: internally .NET serialises frames with a lock, so the bytes of two frames do not get mixed. But after the first part of one thread's message, the first part of another thread's message can slip in between; RFC 6455 does not allow that. With small, single-part messages you might never see this error, which is even more dangerous.

The fix is to have exactly one place that writes to the socket. We give each connection a Channel (the producer/consumer structure from the Channels post of the C# Async series) and a single loop that drains it:

public sealed class WebSocketConnection(WebSocket socket, ILogger<WebSocketConnection> logger)
{
    private const int MaxMessageBytes = 4 * 1024;

    // single reader: only SendLoopAsync writes to the socket
    private readonly Channel<byte[]> _outbox = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(64)
    {
        SingleReader = true,
        FullMode = BoundedChannelFullMode.DropOldest
    });

    private readonly ConcurrentDictionary<int, bool> _orders = new();

    public Guid Id { get; } = Guid.NewGuid();

    public bool IsWatching(int orderId) => _orders.ContainsKey(orderId);

    public bool TryEnqueue(byte[] payload) => _outbox.Writer.TryWrite(payload);

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        Task sendLoop = SendLoopAsync(cancellationToken);
        try
        {
            await ReceiveLoopAsync(cancellationToken);
        }
        finally
        {
            _outbox.Writer.TryComplete();
            await sendLoop;
        }
    }

    private async Task SendLoopAsync(CancellationToken cancellationToken)
    {
        try
        {
            await foreach (byte[] payload in _outbox.Reader.ReadAllAsync(cancellationToken))
            {
                if (socket.State != WebSocketState.Open)
                {
                    break;
                }
                await socket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, cancellationToken);
            }
        }
        catch (OperationCanceledException)
        {
        }
    }
}

This structure solves two problems at once. Sends are now ordered, because there is a single reader. The second is the slow client: if a client on a mobile connection reads messages slowly, SendAsync starts waiting. Without a queue the broadcasting code would wait for it too, and one slow client would slow everybody down. With a bounded channel and DropOldest we say "the slow client loses its oldest messages, the others are unaffected". For data like order status, where the latest value is what matters, that is the right call; where every message matters, such as chat, it is more honest to close the connection when the queue is full and force the client to resynchronise.

Connection Registry and Broadcast

public sealed class ConnectionRegistry
{
    private readonly ConcurrentDictionary<Guid, WebSocketConnection> _connections = new();

    public void Add(WebSocketConnection connection) => _connections[connection.Id] = connection;

    public void Remove(WebSocketConnection connection) => _connections.TryRemove(connection.Id, out _);

    public int Broadcast(int orderId, OrderStatusMessage message)
    {
        byte[] payload = JsonSerializer.SerializeToUtf8Bytes(message, JsonSerializerOptions.Web);
        int delivered = 0;
        foreach (WebSocketConnection connection in _connections.Values)
        {
            if (connection.IsWatching(orderId) && connection.TryEnqueue(payload))
            {
                delivered++;
            }
        }
        return delivered;
    }
}

app.MapPost("/orders/{id:int}/status", (int id, StatusUpdate update, ConnectionRegistry registry) =>
{
    int delivered = registry.Broadcast(id, new OrderStatusMessage("status", id, update.Status));
    return TypedResults.Ok(new { delivered });
});

We serialise the JSON once and put the same byte array into every queue. ConcurrentDictionary.Values returns a copy of the current values (taking all internal locks briefly to do so); connections added or removed during the loop cause no trouble. If you broadcast very often, factor in the cost of that copy. After the client sends {"type":"subscribe","orderId":42}:

received: {"type":"status","orderId":42,"status":"Shipped"}
POST: {"delivered":1}

This registry belongs to a single process. The moment you add a second server, Broadcast only sees its own connections; we solve that in the eighth post of the series.

Angular: RxJS webSocket() and Reconnecting with Backoff

On the client, RxJS's webSocket() function returns a subject that is both an Observable and an Observer: subscribing connects, and next() sends a message. We will cover RxJS operators in detail in the upcoming RxJS series; three of them are enough here.

import { Injectable } from '@angular/core';
import { Observable, retry, timer } from 'rxjs';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';

export interface OrderStatusMessage {
  type: 'status';
  orderId: number;
  status: string;
}

type ClientMessage = { type: 'subscribe' | 'unsubscribe'; orderId: number };

@Injectable({ providedIn: 'root' })
export class OrderSocketService {
  private readonly socket: WebSocketSubject<OrderStatusMessage | ClientMessage> = webSocket({
    url: 'ws://localhost:17602/ws/orders',
    openObserver: { next: () => console.log('[ws] open') },
    closeObserver: { next: (event: CloseEvent) => console.log(`[ws] close ${event.code}`) },
  });

  watchOrder(orderId: number): Observable<OrderStatusMessage> {
    // multiplex: sends subscribe when subscribed and unsubscribe when released
    const messages = this.socket.multiplex(
      () => ({ type: 'subscribe', orderId }),
      () => ({ type: 'unsubscribe', orderId }),
      (message) => message.type === 'status' && message.orderId === orderId,
    ) as Observable<OrderStatusMessage>;

    return messages.pipe(
      retry({
        // reconnect after waiting 1, 2, 4, 8 ... seconds, at most 30
        delay: (_error, retryCount) => {
          const delayMs = Math.min(1000 * 2 ** (retryCount - 1), 30_000);
          console.log(`[ws] retry #${retryCount} in ${delayMs} ms`);
          return timer(delayMs);
        },
        resetOnSuccess: true,
      }),
    );
  }
}

multiplex does the real work here: it opens a logical channel over the same socket and sends the subscribe message itself when subscribed and the unsubscribe message when the subscription is released. When retry resubscribes, multiplex sends the subscribe message again; so after reconnecting you do not have to think separately about "which orders was I watching?". resetOnSuccess resets the counter after a successful message, so the next drop starts again from a 1-second wait.

import { Component, inject, input } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { OrderSocketService } from './order-socket.service';

@Component({
  selector: 'app-order-tracker',
  template: `
    <h2>Order #{{ orderId() }}</h2>
    <p>Status: {{ status()?.status ?? 'waiting...' }}</p>
  `,
})
export class OrderTrackerComponent {
  private readonly orders = inject(OrderSocketService);

  readonly orderId = input.required<number>();

  // when the component is destroyed, toSignal unsubscribes and multiplex sends the unsubscribe message
  protected readonly status = toSignal(
    toObservable(this.orderId).pipe(switchMap((id) => this.orders.watchOrder(id))),
  );
}

I built the app and opened it in the browser, killed the server process with kill -9, restarted it 6 seconds later and POSTed a status update. The browser console:

[ws] open
[ws] close 1006
[ws] retry #1 in 1000 ms
WebSocket connection to 'ws://localhost:17602/ws/orders' failed:
[ws] retry #2 in 2000 ms
[ws] close 1006
WebSocket connection to 'ws://localhost:17602/ws/orders' failed:
[ws] retry #3 in 4000 ms
[ws] close 1006
[ws] open

The page showed "Status: OutForDelivery", and the log of the new server process contained the 33-byte subscribe message; so multiplex renewed the subscription on its own. But note: the updates sent while the server was down were lost. Fetching the current state with an ordinary HTTP request after reconnecting is still your job.

Compression: Why Is "Dangerous" in the Name?

using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync(
    new WebSocketAcceptContext { DangerousEnableCompression = true });
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Date: Sun, 08 Nov 2026 02:08:37 GMT
Server: Kestrel
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15

When the client asks for permessage-deflate, the server accepts and messages are compressed. With this option off, the same request gets a 101 without this header, i.e. the connection is set up uncompressed. The word "Dangerous" in the name comes from compression over TLS (Transport Layer Security) opening the door to CRIME (Compression Ratio Info-leak Made Easy) and BREACH (Browser Reconnaissance and Exfiltration via Adaptive Compression of Hypertext) style attacks: if an attacker can get data they control into the same message as a secret, they can guess the secret byte by byte from changes in the compressed size. For messages carrying tokens or personal data, either keep compression off or send that message with WebSocketMessageFlags.DisableCompression.

Production Notes

Count what we have written so far: a receive loop, a size limit, the close handshake, disconnect detection, a send queue, a registry, a subscription protocol, reconnecting on the client and renewing subscriptions. And there are still gaps:

  • Authentication: the browser cannot add headers to a WebSocket request, so you have to take the token from the query string and keep it out of the logs.
  • Sending per user: three tabs of the same user are three separate connections.
  • Missed messages: a sequence number and resending for what was sent during a drop.
  • Scale-out: connections on the second server that the registry does not know about.
  • Transport fallback: users behind proxies that block WebSocket.

Every one of these comes ready-made in SignalR. That list is exactly why most teams move from raw WebSocket to SignalR. There are still places where raw WebSocket is the right choice: a counterpart with no SignalR client library (an IoT (Internet of Things) device, a game client written in another language), carrying an existing protocol (STOMP (Simple Text Oriented Messaging Protocol), MQTT (Message Queuing Telemetry Transport) over WebSocket), or binary streams where every byte counts.

On the monitoring side, publish the number of open connections, the number of messages dropped from queues and the connections closed with MessageTooBig as metrics. A rising count of dropped messages is the first sign of slow clients or a higher broadcast rate.

Common Pitfalls

Assuming a single ReceiveAsync call returns the whole message. Accumulate until EndOfMessage is true and limit the total size.

Calling SendAsync on the same socket from more than one place. Because you will not see an exception, the test environment will not catch it; use a single writer loop.

Returning from the endpoint delegate while the connection is open. If you put the socket in a list and finish the endpoint, the response completes and the socket becomes unusable.

Defining the WebSocket endpoint with MapGet. You miss the extended CONNECT request arriving over HTTP/2; use Map.

Mistaking the Origin check for authentication. It only blocks browser-originated CSWSH; non-browser clients write the header however they like.

Reconnecting at a fixed interval. When the server restarts, all 10,000 clients try to connect in the same second. Adding a small random deviation (jitter) to exponential backoff spreads that wave out.

A Small Experiment

★☆☆ Lower the receive buffer from 1,024 to 16 bytes and confirm from the log how many reads the 33-byte subscribe message takes.

★★☆ Add ±20% jitter to the backoff in OrderSocketService, then kill and restart the server and observe that the wait times in the console now differ every time.

★★★ Use Wait instead of BoundedChannelFullMode.DropOldest and write a client that never reads messages. Then add an endpoint that broadcasts 100 times per second. How did the latency of the other clients change? Write a version that closes the connection with PolicyViolation when the queue is full.

In the next post we move to the one-way side: we take Server-Sent Events in depth and look at SseItem<T>, resuming with Last-Event-ID, heartbeats, and how proxies silently break an SSE (Server-Sent Events) stream.