If you've built anything non-trivial with WebSockets in .NET, you know the ritual. Accept the connection, allocate a buffer, loop over ReceiveAsync, check EndOfMessage, reconstruct the payload, handle close frames, and somewhere in the middle of all that ceremony, write the two lines of business logic you actually care about. It's the kind of code that works on the third attempt and breaks on the fourth refactor.

The root problem is a mismatch of abstractions. Most of the .NET ecosystem speaks System.IO.Stream: serialisers, compression, logging, pipelines, HTTP content. WebSockets speak frames and messages. Every time you want to use a WebSocket as a transport, you end up writing an adapter -- and everyone's adapter is slightly different.

.NET 10 finally bridges that gap with WebSocketStream, a class in System.Net.WebSockets that wraps a WebSocket in a standard Stream. It handles framing, message boundaries, encoding, and the closing handshake so you can focus on what flows through the pipe.

The boilerplate problem

Consider a typical echo handler in ASP.NET Core before .NET 10:

Middleware/LegacyEchoHandler.cs
async Task HandleWebSocket(WebSocket webSocket)
{
    var buffer = new byte[4096];
    var messageBuilder = new StringBuilder();

    while (webSocket.State == WebSocketState.Open)
    {
        var result = await webSocket.ReceiveAsync(
            buffer, CancellationToken.None);

        if (result.MessageType == WebSocketMessageType.Close)
        {
            await webSocket.CloseAsync(
                WebSocketCloseStatus.NormalClosure,
                "Done",
                CancellationToken.None);
            break;
        }

        messageBuilder.Append(
            Encoding.UTF8.GetString(buffer, 0, result.Count));

        if (result.EndOfMessage)
        {
            var message = messageBuilder.ToString();
            var responseBytes = Encoding.UTF8.GetBytes($"Echo: {message}");

            await webSocket.SendAsync(
                responseBytes,
                WebSocketMessageType.Text,
                endOfMessage: true,
                CancellationToken.None);

            messageBuilder.Clear();
        }
    }
}

That's roughly 30 lines for "read a message, echo it back." The buffer size is arbitrary. The EndOfMessage check is easy to forget. The UTF-8 encoding is manual. The close handshake is your responsibility. And if you want to integrate this with System.Text.Json, StreamReader, or any compression stream, you're writing yet another layer.

What WebSocketStream changes

WebSocketStream is a System.IO.Stream subclass that delegates reads and writes to a wrapped WebSocket. It lives in the System.Net.WebSockets namespace, ships in System.Net.WebSockets.dll, and requires no additional NuGet packages on .NET 10 or later.

The class exposes three static factory methods:

Method Purpose
Create(WebSocket, WebSocketMessageType, bool) Bidirectional stream for continuous read/write protocols
Create(WebSocket, WebSocketMessageType, TimeSpan) Same, but with a close timeout instead of ownership transfer
CreateReadableMessageStream(WebSocket) Read-only stream scoped to a single inbound message
CreateWritableMessageStream(WebSocket, WebSocketMessageType) Write-only stream scoped to a single outbound message

The Create overloads give you a long-lived stream suitable for protocols like STOMP or AMQP where a single WebSocket connection carries a continuous flow of data. The message-scoped methods (CreateReadableMessageStream and CreateWritableMessageStream) are for request/response patterns where each message is a discrete unit you want to deserialise or serialise independently.

Streaming text protocols

The most immediate win is text-based protocols. Where you previously juggled WebSocketMessageType, UTF-8 encoding, and EndOfMessage flags, you can now use StreamReader and StreamWriter:

Middleware/StreamingTextHandler.cs
async Task HandleTextProtocol(WebSocket webSocket, CancellationToken ct)
{
    using var stream = WebSocketStream.Create(
        webSocket,
        WebSocketMessageType.Text,
        ownsWebSocket: true);

    using var reader = new StreamReader(stream, leaveOpen: true);
    await using var writer = new StreamWriter(stream, leaveOpen: true)
    {
        AutoFlush = true
    };

    string? line;
    while ((line = await reader.ReadLineAsync(ct)) is not null)
    {
        await writer.WriteLineAsync($"Echo: {line}");
    }
}

The stream handles UTF-8 encoding and decoding. StreamReader handles line splitting. The closing handshake fires automatically when the stream is disposed. The entire echo handler is now declarative rather than procedural.

// TIP

Pass leaveOpen: true to StreamReader if you're also using the stream for writing. Without it, the reader disposes the underlying stream when it's garbage-collected, which tears down the WebSocket prematurely.

Streaming binary protocols

Binary protocols benefit equally. For a protocol like AMQP tunnelled over WebSockets, you can serialise directly to and from the stream:

Services/BinaryTransport.cs
async Task SendAndReceiveAsync(WebSocket webSocket, CancellationToken ct)
{
    using var stream = WebSocketStream.Create(
        webSocket,
        WebSocketMessageType.Binary,
        closeTimeout: TimeSpan.FromSeconds(10));

    await message.SerializeToStreamAsync(stream, ct);

    var responsePayload = new byte[expectedPayloadLength];
    await stream.ReadExactlyAsync(responsePayload, ct);
}

The closeTimeout overload tells the stream how long to wait for a graceful closing handshake when disposed. If the remote side doesn't complete the close within that window, the stream aborts the connection.

Single-message patterns with JSON

Real-world WebSocket APIs often exchange discrete JSON messages. The message-scoped factory methods are designed for exactly this scenario:

Handlers/OrderHandler.cs
async Task<Order?> ReadNextOrderAsync(WebSocket webSocket)
{
    using var messageStream =
        WebSocketStream.CreateReadableMessageStream(webSocket);

    return await JsonSerializer.DeserializeAsync<Order>(messageStream);
}

CreateReadableMessageStream returns a stream that reads from exactly one WebSocket message. When JsonSerializer.DeserializeAsync calls ReadAsync internally, it gets the message's bytes. Once the message ends, reads return zero bytes -- the signal JsonSerializer uses to know the input is complete. No manual buffer management, no EndOfMessage checking.

The write side mirrors this pattern:

Handlers/OrderHandler.cs
async Task SendOrderConfirmationAsync(
    WebSocket webSocket,
    OrderConfirmation confirmation)
{
    using var messageStream =
        WebSocketStream.CreateWritableMessageStream(
            webSocket, WebSocketMessageType.Text);

    await JsonSerializer.SerializeAsync(messageStream, confirmation);
}

When the writable message stream is disposed, it sends the end-of-message frame to the WebSocket. Everything serialised before disposal becomes a single WebSocket message.

Composing with the stream ecosystem

The real power of WebSocketStream is that it makes WebSockets a first-class citizen in the Stream ecosystem. Any API that accepts a Stream now works with WebSockets:

Compression:

Services/CompressedTransport.cs
using var wsStream = WebSocketStream.Create(
    webSocket, WebSocketMessageType.Binary, ownsWebSocket: true);
using var compressed = new GZipStream(wsStream, CompressionLevel.Optimal);

await payload.CopyToAsync(compressed);

System.IO.Pipelines:

Services/PipelineTransport.cs
using var wsStream = WebSocketStream.Create(
    webSocket, WebSocketMessageType.Binary, ownsWebSocket: true);

var pipeReader = PipeReader.Create(wsStream);
var pipeWriter = PipeWriter.Create(wsStream);

Binary serialisation:

Services/BinaryMessageReader.cs
using var wsStream = WebSocketStream.CreateReadableMessageStream(webSocket);
using var binaryReader = new BinaryReader(wsStream);

int messageType = binaryReader.ReadInt32();
long timestamp = binaryReader.ReadInt64();

This composability is the fundamental shift. Before .NET 10, every WebSocket integration with a stream-based API required a custom adapter. Now the adapter ships in the BCL.

An ASP.NET Core endpoint, end to end

Here's a complete minimal API example that accepts WebSocket connections and processes JSON messages in a loop:

Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseWebSockets();

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

    using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
    using var stream = WebSocketStream.Create(
        webSocket, WebSocketMessageType.Text, ownsWebSocket: true);

    using var reader = new StreamReader(stream, leaveOpen: true);
    await using var writer = new StreamWriter(stream, leaveOpen: true)
    {
        AutoFlush = true
    };

    string? line;
    while ((line = await reader.ReadLineAsync()) is not null)
    {
        var order = JsonSerializer.Deserialize<Order>(line);
        if (order is null) continue;

        var confirmation = new OrderConfirmation(
            order.Id, DateTimeOffset.UtcNow, "Accepted");

        await writer.WriteLineAsync(
            JsonSerializer.Serialize(confirmation));
    }
});

app.Run();

Compare that to the equivalent code without WebSocketStream -- it's roughly half the size, with no manual buffer management, no EndOfMessage juggling, and no explicit close handling.

Under the hood

WebSocketStream does not introduce a new protocol or change the wire format. It's a thin adapter with a few important behaviours:

Common pitfalls

Forgetting leaveOpen: true on readers and writers. If you wrap the stream in both a StreamReader and a StreamWriter, you need leaveOpen: true on at least one of them. Otherwise, disposing the reader kills the stream before the writer can flush.

Using the wrong factory method. The bidirectional Create gives you a continuous stream where reads cross message boundaries. If your protocol treats each WebSocket message as a discrete unit (like individual JSON payloads), use CreateReadableMessageStream and CreateWritableMessageStream instead -- or the messages will bleed into each other.

Assuming synchronous reads work well. WebSocketStream supports synchronous Read and Write, but they block the calling thread while waiting for network I/O. Stick to ReadAsync and WriteAsync in production code.

Ignoring cancellation tokens. The ReadAsync and WriteAsync overloads accept CancellationToken. Without one, a slow or unresponsive client can hold your server thread indefinitely.

Not handling WebSocketException. The stream doesn't swallow exceptions from the underlying WebSocket. A dropped connection surfaces as a WebSocketException during reads or writes -- wrap your I/O in appropriate error handling.

// WARNING

WebSocketStream does not perform any message-level buffering beyond what the underlying WebSocket provides. If you're reading large messages with small buffers, you'll still make many round trips to the underlying socket. For high-throughput scenarios, consider layering BufferedStream or System.IO.Pipelines on top.

When to use WebSocketStream vs SignalR

WebSocketStream is a transport-level primitive. It gives you a Stream over a WebSocket -- nothing more. You handle your own message serialisation, routing, reconnection, and protocol design.

SignalR is a full application-level abstraction that happens to use WebSockets as one of its transports. It gives you hub methods, automatic reconnection, client-to-server invocation, and protocol negotiation.

Use WebSocketStream when you need a raw, lightweight transport -- when you're implementing a custom protocol, tunnelling an existing stream-based protocol over WebSockets, or integrating with systems that speak a specific wire format. Use SignalR when you want application-level RPC semantics and don't want to reinvent the plumbing.

Summary