Build real-time, bidirectional WebSocket services in ASP.NET Core β with a thread-safe connection manager, echo + broadcast messaging, and a REST companion API for server-initiated pushes.
If this sample saved you time, consider joining our Patreon community. You'll get exclusive .NET tutorials, premium code samples, and early access to new content β all for the price of a coffee.
π Join CodingDroplets on Patreon
Prefer a one-time tip? Buy us a coffee β
- How to enable and configure WebSockets in ASP.NET Core with
UseWebSockets() - How to accept WebSocket upgrade requests using
context.WebSockets.AcceptWebSocketAsync() - How to implement a thread-safe connection manager with
ConcurrentDictionary - How to echo messages back to the sender and broadcast to all connected clients
- How to detect and remove dead connections during broadcast
- How to build a REST companion API for server-initiated WebSocket pushes
- When to use WebSockets vs SignalR (and why SignalR is often the better choice)
- How to unit-test the connection manager without a real network (using
WebSocketstubs) - How to integration-test the REST endpoints with
WebApplicationFactory
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ASP.NET Core Application β
β β
β HTTP/REST WebSocket (ws://) β
β ββββββββββββββββββββββββ ββββββββββββββββββββββββββββ β
β β ConnectionsControllerβ β /ws Map endpoint β β
β β β β β β
β β GET /api/connections β β AcceptWebSocketAsync() β β
β β POST /api/connections β β β β β
β β /broadcast β β βΌ β β
β ββββββββββββββ¬ββββββββββ β WebSocketHandler β β
β β β (receive loop) β β
β β ββββββββββββββ¬ββββββββββββββ β
β β β β
β ββββββββββββββββββββ¬ββββββββββββββββ β
β βΌ β
β βββββββββββββββββββββββββββββββ β
β β WebSocketConnectionManager β β
β β ConcurrentDictionary β β
β β connectionId β WebSocket β β
β β β β
β β AddSocket() β id β β
β β BroadcastAsync() β all β β
β β SendToAsync() β one β β
β β RemoveSocket() β cleanup β β
β βββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
WebSocket Message Flow:
Client sends text β WebSocketHandler receives β echo back to sender
β broadcast to all others
| Feature | Raw WebSockets (this sample) | SignalR |
|---|---|---|
| Complexity | Low β you own everything | Medium β abstracted |
| Protocol | WebSocket (RFC 6455) only | WebSocket, SSE, long-polling |
| Typed hub methods | β Manual JSON parsing | β Strongly-typed hubs |
| Groups / user targeting | β Manual tracking | β Built-in groups & users |
| Reconnection handling | β Manual | β Automatic |
| Backplane (scale-out) | β Manual Redis/custom | β Azure SignalR / Redis |
| Best for | IoT, binary streams, proxies | Chat, live dashboards, games |
Rule of thumb: Use raw WebSockets when you need full protocol control (binary data, custom framing, proxying). Use SignalR for everything else.
dotnet-websockets-aspnetcore/
β
βββ dotnet-websockets-aspnetcore.sln # Solution file
β
βββ WebSocketsDemo.Api/ # Main Web API project
β βββ Controllers/
β β βββ ConnectionsController.cs # REST: list connections + server broadcast
β βββ WebSockets/
β β βββ WebSocketConnectionManager.cs # Thread-safe connection pool
β β βββ WebSocketHandler.cs # Per-connection receive/echo/broadcast loop
β βββ Models/
β β βββ ConnectionInfo.cs # Response DTOs
β βββ Properties/
β β βββ launchSettings.json # Opens Swagger UI automatically
β βββ Program.cs # Middleware pipeline + /ws endpoint
β
βββ WebSocketsDemo.Tests/ # xUnit test project
βββ ConnectionManagerTests.cs # Unit tests (9 tests, stubs WebSocket)
βββ ConnectionsApiTests.cs # Integration tests (4 tests, WebApplicationFactory)
| Tool | Version | Download |
|---|---|---|
| .NET SDK | 10.0+ | https://dotnet.microsoft.com/download |
| websocat (optional) | Any | cargo install websocat or GitHub releases |
| IDE | VS 2022 / Rider / VS Code | Any will work |
# 1. Clone the repository
git clone https://github.com/codingdroplets/dotnet-websockets-aspnetcore.git
cd dotnet-websockets-aspnetcore
# 2. Build
dotnet build -c Release
# 3. Run the API
cd WebSocketsDemo.Api
dotnet run
# 4. Open Swagger UI (Visual Studio opens automatically)
http://localhost:5289/swagger
# 5. Connect a WebSocket client
websocat ws://localhost:5289/ws
# 6. Type a message and press Enter β it will echo back and broadcast to all connected clients
Hello, WebSockets!// IMPORTANT: Must be called before UseRouting() / MapControllers()
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30), // Ping to detect dead connections
});app.Map("/ws", async (HttpContext context) =>
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400;
return;
}
var webSocket = await context.WebSockets.AcceptWebSocketAsync();
var connectionId = manager.AddSocket(webSocket);
// HandleAsync blocks until the client disconnects
await handler.HandleAsync(connectionId, webSocket, context.RequestAborted);
});public sealed class WebSocketConnectionManager
{
private readonly ConcurrentDictionary<string, WebSocket> _sockets = new();
public string AddSocket(WebSocket socket)
{
var id = Guid.NewGuid().ToString("N");
_sockets.TryAdd(id, socket);
return id;
}
public async Task BroadcastAsync(string message, CancellationToken ct = default)
{
var buffer = Encoding.UTF8.GetBytes(message);
foreach (var (id, socket) in _sockets)
{
if (socket.State == WebSocketState.Open)
await socket.SendAsync(buffer, WebSocketMessageType.Text, true, ct);
}
}
}var buffer = new byte[4096];
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
while (!result.CloseStatus.HasValue)
{
var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
await manager.SendToAsync(connectionId, JsonSerializer.Serialize(new { type="echo", message=text }), ct);
await manager.BroadcastAsync(JsonSerializer.Serialize(new { type="message", from=connectionId[..8], message=text }), ct);
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
}
await webSocket.CloseAsync(result.CloseStatus!.Value, result.CloseStatusDescription, ct);| Protocol | Method | Endpoint | Description | Success | Error |
|---|---|---|---|---|---|
| WebSocket | β | ws://host/ws |
Connect a WebSocket client | 101 Switching Protocols | β |
| HTTP | GET |
/api/connections |
List active WebSocket connections | 200 OK | β |
| HTTP | POST |
/api/connections/broadcast |
Broadcast message to all WS clients | 200 OK | 400 |
System message (join/leave):
{ "type": "system", "message": "Client a1b2c3d4 joined.", "timestamp": "2026-06-13T06:00:00Z" }Echo (back to sender):
{ "type": "echo", "from": "a1b2c3d4", "message": "Hello!", "timestamp": "2026-06-13T06:00:01Z" }Broadcast (to all clients):
{ "type": "message", "from": "a1b2c3d4", "message": "Hello!", "timestamp": "2026-06-13T06:00:01Z" }Server-initiated broadcast (via POST /api/connections/broadcast):
{ "type": "server-broadcast", "message": "Server is restarting in 5 minutes.", "timestamp": "2026-06-13T06:00:00Z" }dotnet test -c Release| Test | Type | Verifies |
|---|---|---|
AddSocket_ReturnsUniqueConnectionId |
Unit | Each AddSocket generates a unique ID |
AddSocket_IncreasesCount |
Unit | Count increments on add |
RemoveSocket_DecreasesCount |
Unit | Count decrements on remove |
RemoveSocket_UnknownId_ReturnsNull |
Unit | Graceful handling of unknown IDs |
GetSocket_ReturnsRegisteredSocket |
Unit | Lookup returns the exact same instance |
GetSocket_UnknownId_ReturnsNull |
Unit | Returns null for missing connections |
ConnectionIds_ReturnsAllRegisteredIds |
Unit | All IDs are enumerable |
SendToAsync_UnknownId_ReturnsFalse |
Unit | Returns false for missing connection |
SendToAsync_ClosedSocket_ReturnsFalse |
Unit | Skips sending to closed socket |
GetConnections_Returns200_WithEmptyPool |
Integration | REST endpoint returns correct schema |
Broadcast_EmptyMessage_Returns400 |
Integration | Validates empty message input |
Broadcast_ValidMessage_Returns200_WithZeroRecipients |
Integration | Server broadcast succeeds with no clients |
WsEndpoint_PlainHttpRequest_Returns400 |
Integration | Non-WebSocket request rejected correctly |
Result: 13/13 passing β
WebSocket connections arrive from multiple concurrent HTTP requests on different threads. Using a regular Dictionary without synchronisation would cause data races. ConcurrentDictionary is the right default β internally partitioned for low-contention concurrent access.
Sometimes you need to push a message from a background job, a Hangfire task, or another HTTP request β not from a WebSocket client. The POST /api/connections/broadcast endpoint lets any server-side code send WebSocket messages without needing to hold an active socket.
TCP connections can silently die (NAT timeout, proxy drops, client crashes) without sending a WebSocket Close frame. KeepAliveInterval tells ASP.NET Core to send WebSocket ping frames every N seconds. If the client doesn't respond, the connection is closed and cleaned up.
Notice: manager.AddSocket(webSocket) is called in Program.cs (before HandleAsync), and HandleAsync is given the already-registered connection ID. This ensures the socket is in the pool before any messages start flowing, which prevents a race where a broadcast fires before a new connection is fully registered.
- ASP.NET Core 10 β Web API + WebSocket middleware
IWebSocketManagerβ Built-in WebSocket upgrade supportConcurrentDictionaryβ Thread-safe connection pool- Swashbuckle / Swagger UI β REST API documentation
- xUnit β Unit and integration test framework
WebApplicationFactoryβ Integration test host
- WebSockets support in ASP.NET Core β Microsoft Docs
- RFC 6455: The WebSocket Protocol
- System.Net.WebSockets.WebSocket β Microsoft Docs
This project is licensed under the MIT License.
| Platform | Link |
|---|---|
| π Website | https://codingdroplets.com/ |
| πΊ YouTube | https://www.youtube.com/@CodingDroplets |
| π Patreon | https://www.patreon.com/CodingDroplets |
| β Buy Me a Coffee | https://buymeacoffee.com/codingdroplets |
| π» GitHub | http://github.com/codingdroplets/ |
Want more samples like this? Support us on Patreon or buy us a coffee β β every bit helps keep the content coming!