A from-scratch dive into the WebSocket protocol — connection lifecycle, broadcasting, background persistence, and retry resilience — built in .NET 9 as a hands-on, project-based learning exercise.
A simple real-time communication example: a .NET 9 server that accepts WebSocket connections and a minimal web client for manual testing.
| Language / Runtime | C# on .NET 9 |
| Protocol | WebSocket (RFC 6455) |
| Persistence | EF Core, via an in-memory queue + background job with retry |
| Client | Vanilla HTML/CSS/JS test client (included) |
| Status | Core protocol + persistence pipeline functional; hardening features on the roadmap |
- What is a WebSocket, and how is it different from HTTP?
- Architecture
- Project Structure
- Technology Stack
- Getting Started
- Implemented Features
- Message Formats
- Project Status
- Roadmap
- References & Learning Resources
- License
HTTP is request/response and stateless: the client opens a connection, sends one request, the server sends back one response, and (logically) the exchange is done. Every new piece of data means a new request. Even with keep-alive reusing the underlying TCP socket, the protocol itself is still one-shot question → answer, and only the client can initiate.
sequenceDiagram
participant Client
participant Server
Client->>Server: HTTP GET /messages
Server-->>Client: 200 OK (data)
Note over Client,Server: Exchange complete, nothing pending
Client->>Server: HTTP GET /messages (poll again later)
Server-->>Client: 200 OK (data)
Note over Client,Server: Server can never push on its own —
Note over Client,Server: client has to keep asking
A WebSocket starts its life as a normal HTTP request that asks to be "upgraded" (Connection: Upgrade, Upgrade: websocket). If the server agrees, it replies 101 Switching Protocols, and from that point on the same TCP connection is kept open and reused as a raw, full-duplex channel. There's no more request/response pairing — either side can send a message at any time, with very little framing overhead per message.
sequenceDiagram
participant Client
participant Server
Client->>Server: HTTP GET /ws (Upgrade: websocket)
Server-->>Client: 101 Switching Protocols
Note over Client,Server: Handshake done — connection stays open
Server-->>Client: { type: "welcome", connectionId }
Client->>Server: text message
Server-->>Client: { type: "broadcast", ... } (to other clients)
Server-->>Client: { type: "user_left", ... } (pushed anytime, unprompted)
Client->>Server: text message
Note over Client,Server: No new connection needed — both sides
Note over Client,Server: can send whenever they want
| HTTP | WebSocket | |
|---|---|---|
| Connection lifecycle | New request/response cycle each time | One persistent TCP connection, opened once via handshake |
| Who can initiate | Client only | Either side, anytime (full-duplex) |
| Per-message overhead | Full headers every request | Handshake once, then lightweight frames |
| Typical use case | Fetching pages/APIs, request-driven data | Real-time updates, chat, live broadcasts, notifications |
This is exactly what this project's middleware does: it accepts an incoming HTTP request, performs the upgrade handshake, then hands the now-persistent connection off to WebSocketServerManager to track and broadcast over for as long as the client stays connected.
Once a client is upgraded, two things happen on every incoming message: it's broadcast live to other connected clients, and it's queued for durable persistence in the background — so a slow or failing database write never blocks or drops a live connection.
flowchart LR
subgraph Clients
C1[Web Client A]
C2[Web Client B]
end
C1 <-->|WebSocket| MW[WebSocket Middleware]
C2 <-->|WebSocket| MW
MW --> MGR[WebSocketServerManager]
MGR -->|broadcast| C1
MGR -->|broadcast| C2
MW -->|enqueue MessageEvent| Q[(In-Memory Message Queue)]
Q --> BG[MessageBackgroundJob]
BG -->|persist, retry on failure| DB[(Database via EF Core)]
style Clients fill:#1118270d,stroke:#888
Flow:
- A client connects →
WebSocketMiddlewareperforms the upgrade handshake and assigns aconnectionId. WebSocketServerManagertracks the connection and broadcasts incoming messages to everyone else.- In parallel, the middleware enqueues the message onto an in-memory channel rather than writing to the database directly.
MessageBackgroundJobdequeues messages and persists them via the repository layer, retrying with linear backoff (RetryHelper) on transient DB failures.- On disconnect, remaining clients get a
user_leftnotification with the updated connection count.
This separation — live broadcast on the fast path, durable persistence on a decoupled background path — is the core architectural idea of the project.
websocket/
├── server/ # C# .NET server application
│ ├── Program.cs # Application entry point
│ ├── Data/ # Persistence, entities, repositories
│ ├── Extensions/ # Service & middleware extension helpers
│ ├── Manager/ # WebSocket connection manager
│ ├── Middleware/ # WebSocket middleware
│ └── Properties/ # Project properties
├── clients/ # Client applications
│ └── web-client/ # Web-based client used for manual testing
│ ├── index.html # Main HTML
│ ├── script.js # Client logic
│ └── style.css # Styling
└── README.md # This file
- Backend: C# (.NET 9)
- Frontend: HTML, CSS, JavaScript (vanilla)
- Communication: WebSocket (RFC6455)
- .NET 9.0 SDK or later
- A modern web browser
-
Open a terminal and change to the server directory:
cd server -
Restore dependencies (optional if already restored):
dotnet restore
-
Run the server:
dotnet run
By default the app is reachable on the local host URLs used by ASP.NET Core (you can also check Properties/launchSettings.json). The included web client defaults to ws://localhost:5000.
- Open clients/web-client/index.html in your browser.
- Click Connect.
- Use the UI to send messages, observe incoming broadcasts and connection events in the communication log.
Connection handling
- WebSocket middleware accepts upgrade requests and assigns a GUID
connectionIdto each client. -
WebSocketServerManagertracks active connections and safely broadcasts messages to other clients. - Server sends an initial payload with the assigned
connectionIdand awelcomemessage on connect. - Close-reason support — clients and server include close reason and close status (if present) when a connection is closed.
Messaging
- On message, server broadcasts a JSON payload to other connected clients:
{ "type": "broadcast", "connectionId": "<id>", "message": "...", "timestamp": "YYYY-MM-DD HH:mm:ss" }. - On disconnect, server notifies other clients:
{ "type": "user_left", "connectionId": "<id>", "totalConnections": N }.
Persistence pipeline
- Message persistence — incoming messages are persisted to the database through the repository layer (
Messageentity,WebSocketDbContext,IMessageRepository/MessageRepository), laying the groundwork for history-on-connect and replay. - Background message queue — messages are queued onto an in-memory channel instead of being persisted directly on the WebSocket receive path, decoupling DB writes from the middleware and keeping client connections responsive even under temporary DB failures.
- Background persistence job —
MessageBackgroundJobcontinuously dequeues messages and persists them with linear backoff retry logic viaRetryHelper(default: starts at 1 second, configurable max retries). Messages that exhaust retries are logged and dropped to avoid infinite loops.
Wiring
- Services and middleware registered via
ServiceCollectionExtensions.RegisterServices()and activated withUseWebSocketServer().
Click to expand full message shapes
| Message | Sent when | Shape |
|---|---|---|
| Connection ID | Immediately after accept | { "connectionId": "<id>" } |
| Welcome | Immediately after accept | { "type": "welcome", "message": "..." } |
| Broadcast | A client sends a text message | { "type": "broadcast", "connectionId": "<id>", "message": "...", "timestamp": "..." } |
| User left | A client disconnects | { "type": "user_left", "connectionId": "<id>", "totalConnections": N } |
Close events may also include a WebSocket close status and an optional close reason string. Clients should inspect the close info to present user-friendly messages or to drive reconnection logic.
Functional: WebSocket connection handling, broadcasting, graceful disconnects, message persistence via background queue, and retry logic with linear backoff are fully implemented. The background service decouples database writes from the WebSocket middleware, ensuring that temporary database failures do not impact client connections. Manual testing via the bundled web client is supported.
- Move persistence to a background queue — messages are dequeued from an in-memory channel by a background job, decoupling DB writes from the WebSocket middleware.
- Add retry logic for DB failures — linear backoff retry strategy via
RetryHelperfor transient database failures when persisting messages. - Console client — a console client for testing/automation and performance benchmarking.
- Message history on connect — load recent message history for clients when they connect so they can catch up.
- Message ordering guarantee — ensure messages are delivered/processed in a well-defined order (server-side sequence numbers or persisted sequence metadata).
- Backpressure handling — detect and react when clients or the server are overloaded (pause reads, drop/queue messages, slow producers).
- Durable background queue — replace the in-memory queue with a persistent one (RabbitMQ, Kafka, or Azure Service Bus) so messages survive server restarts.
- Per-connection rate limiting — protect the server from abusive clients with configurable message thresholds.
- Authentication / authorization — JWT tokens or basic auth for secure client connections.
This project was inspired and informed by the following materials:
-
RFC 6455 – The WebSocket Protocol https://datatracker.ietf.org/doc/html/rfc6455
-
Building Production-Ready WebSocket Servers in C# ASP.NET Core https://medium.com/@bhargavkoya56/building-production-ready-websocket-servers-in-c-asp-net-core-927b737f14cc
-
Writing a WebSocket server (MDN Web Docs) https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_server
-
ASP.NET Core WebSockets vs SignalR – Which should you use? (Full Course) — Les Jackson https://www.youtube.com/watch?v=ycVgXe6v1VQ
See LICENSE