WebSockets in .NET 9: A Getting Started Guide
Learn how to implement WebSocket server endpoints using C# with ASP .NET Core 9 Minimal API.
WebSockets are the simplest way to provide real-time communication between front-end and back-end apps. And it's surely possible to use ASP .NET Core
for that communication style. However, Microsoft's documentation for that technology seems poor and almost abandoned. In this article, I will try to fill the gap, giving you the jump start with WebSockets using the latest .NET
stack.
Or jump straight to the end for the TLDR
The Naive Approach: One-Time Echo
Let's try by creating a project, to run our experiments on. We'll use the default web
template:
dotnet new web --name Playground.WebSockets
WebSockets are not enabled by default. Gladly, to enable them we just need to add a single line:
app.UseWebSockets();
To implement a WebSocket endpoint we'll need to extract the webSocket
from the HttpContext
, receive bytes from it, and just send the same bytes back. Here's what our first naive implementation might look like:
app.Map("/echo", async (HttpContext context) => {
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var buffer = new byte[8];
await webSocket.ReceiveAsync(buffer, CancellationToken.None);
await webSocket.SendAsync(
buffer,
WebSocketMessageType.Text,
endOfMessage: true,
CancellationToken.None
);
});
And here's how we can use the newly created endpoint:
Hooray, we made a WebSockets endpoint using .NET
! Of course for now our endpoint has a bunch of problems. Let's address them one by one.
Improving The Echo: Reading the Whole Message
First, let's try sending a longer message to the endpoint:
As you may see, the endpoint returns just the first part of the message. WebSockets split messages into frames and a single WebSocket message can be fragmented into multiple frames. When we call ReceiveAsync
we receive the frame, based on the length of buffer
we supply. Gladly, WebSocketReceiveResult
identifies whether the frame indicates the end of the message. So we need to ReceiveAsync
to a joined buffer, while we don't reach the end of the message. Here's the code for an extension method that implements this:
public static class WebSocketExtensions
{
public static async Task<byte[]> ReceiveToTheEndAsync(this WebSocket webSocket, CancellationToken? cancellationToken = null)
{
var buffer = new byte[8];
using var memoryStream = new MemoryStream();
WebSocketReceiveResult result;
do
{
result = await webSocket.ReceiveAsync(buffer, cancellationToken ?? CancellationToken.None);
memoryStream.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return memoryStream.ToArray();
}
}
Of course, in a real application we will use a longer byte array length, and the
Hello WebSockets!
will fit the size. However, in a real application, the message could be significantly longer, so we will still need a way to make sure we have read the message till the end.
Let's also wrap SendAsync
in a shorter method to simplify our code a little further:
public static async Task SendFullTextAsync(this WebSocket webSocket, ArraySegment<byte> buffer, CancellationToken? cancellationToken = null)
{
await webSocket.SendAsync(
buffer,
WebSocketMessageType.Text,
endOfMessage: true,
cancellationToken ?? CancellationToken.None
);
}
With these methods in hand, let's create a new shorter version of our echo endpoint:
app.Map("/echo-long", async (HttpContext context) => {
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var buffer = await webSocket.ReceiveToTheEndAsync();
await webSocket.SendFullTextAsync(buffer);
});
And let's test the new method with our previous "long" message:
One Problem solved! A few more to go!
Echoing Forever: Keeping the WebSocket
As you may notice, after we send the echo we interrupt our WebSocket connection. Of course, this is not acceptable since it violates the whole purpose of using WebSockets. Don't worry, we'll fix it right now!
The simplest way to keep the connection is to loop while
there's no cancellation requested from the CancellationToken
. Like this:
app.Map("/echo-forever", async (HttpContext context, CancellationToken cancellationToken) => {
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
while (!cancellationToken.IsCancellationRequested)
{
var buffer = await webSocket.ReceiveToTheEndAsync();
await webSocket.SendFullTextAsync(buffer);
}
});
Let's test what we get now:
Great, now we can echo for however long we wish! But see the exception popping in the logs? Let's handle it!
The Almost Perfect Echo: Sending The Close Frame
If we study the exception just for a little we will find that it happens because we try to receive our next frame, despite the WebSocket not being open anymore. This is an easy fix! We just need to check that webSocket.State == WebSocketState.Open
before running a new cycle of message receiving. With the fix and a few logging added for transparency, we can get a code like this:
app.Map("/echo-forever-unexceptioned", async (HttpContext context, CancellationToken cancellationToken) => {
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
while (!cancellationToken.IsCancellationRequested && webSocket.State == WebSocketState.Open)
{
var buffer = await webSocket.ReceiveToTheEndAsync(cancellationToken);
app.Logger.LogInformation("Received: {buffer}", Encoding.UTF8.GetString(buffer));
await webSocket.SendFullTextAsync(buffer, cancellationToken);
}
app.Logger.LogInformation("WebSocket closed");
});
Let's test this now:
We stopped receiving the exception! But, as you might see, Postman shows a warning sign next to the closing message. This is because proper closing of WebSockets connection implies receiving Close
confirmation from the party opposite to the closing initiation - in this case, our server. Well, we just need to send the WebSocketCloseStatus.NormalClosure
from our server after receiving a closing request from the client. Like this:
if (webSocket.State == WebSocketState.CloseReceived)
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", CancellationToken.None);
}
Let's test it again:
Finally, our endpoint functions properly. Now, there's just one thing left to do!
The Final Version
At the beginning of the article, we wrote a helper extension method for WebSockets. That's a shame we would have to write such low-level methods ourselves. Gladly, there's a dedicated nuget package containing similar extension methods. Let's install it:
dotnet add package Nist.WebSockets
Now, let's write the final version of our echo endpoint, utilizing the package:
using Nist;
// ...
app.Map("/echo-forever-final", async (HttpContext context, CancellationToken cancellationToken) => {
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
while (!cancellationToken.IsCancellationRequested && webSocket.State == WebSocketState.Open)
{
var buffer = await webSocket.ReceiveAsync(cancellationToken: cancellationToken);
app.Logger.LogInformation("Received: {buffer}", Encoding.UTF8.GetString(buffer));
await webSocket.SendAsync(buffer, cancellationToken: cancellationToken);
}
if (webSocket.State == WebSocketState.CloseReceived)
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", CancellationToken.None);
}
app.Logger.LogInformation("WebSocket closed");
});
Now, let's do the final test!
With the help of the package, we will need way less code to write a properly functioning WebSockets endpoint! The package is part of the Nist project, providing tools for Nice State Transfer. In the repository, you can find the article source code as well as the package source code. Give it a Star on the Github! And also ... claps for the article are appreciated 👉👈