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!