Skip to content

codingdroplets/dotnet-websockets-aspnetcore

Repository files navigation

dotnet-websockets-aspnetcore

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.

Visit CodingDroplets YouTube Patreon Buy Me a Coffee GitHub


🚀 Support the Channel — Join on Patreon

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 ☕


🎯 What You'll Learn

  • 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 WebSocket stubs)
  • How to integration-test the REST endpoints with WebApplicationFactory

🗺️ Architecture Overview

┌────────────────────────────────────────────────────────────────────┐
│                  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

📋 WebSocket vs SignalR — Decision Guide

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.


📁 Project Structure

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)

🛠️ Prerequisites

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

⚡ Quick Start

# 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!

🔧 How It Works

Step 1 — Enable WebSocket middleware in Program.cs

// IMPORTANT: Must be called before UseRouting() / MapControllers()
app.UseWebSockets(new WebSocketOptions
{
    KeepAliveInterval = TimeSpan.FromSeconds(30), // Ping to detect dead connections
});

Step 2 — Accept WebSocket upgrades at the /ws endpoint

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);
});

Step 3 — Thread-safe connection pool

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);
        }
    }
}

Step 4 — Message receive loop

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);

📡 API Endpoints

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

Example WebSocket Message Formats (JSON)

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" }

🧪 Running Tests

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 ✅


🤔 Key Concepts

Why ConcurrentDictionary?

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.

Why a REST companion API?

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.

Why KeepAliveInterval?

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.

The AddSocket before HandleAsync pattern

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.


🏷️ Technologies Used

  • ASP.NET Core 10 — Web API + WebSocket middleware
  • IWebSocketManager — Built-in WebSocket upgrade support
  • ConcurrentDictionary — Thread-safe connection pool
  • Swashbuckle / Swagger UI — REST API documentation
  • xUnit — Unit and integration test framework
  • WebApplicationFactory — Integration test host

📚 References


📄 License

This project is licensed under the MIT License.


🔗 Connect with CodingDroplets

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!

About

WebSockets in ASP.NET Core: thread-safe connection manager, echo and broadcast messaging, REST companion API for server-initiated pushes, and 13 unit + integration tests.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages