Advanced Track
WebSockets in .NET
By the end of this lesson you'll understand how a WebSocket upgrades a one-shot HTTP request into a persistent, two-way channel — and you'll be able to accept a connection in ASP.NET Core, run a receive loop that reads and sends framed messages, close the connection cleanly, and decide when to reach for raw WebSockets instead of SignalR.
What You'll Learn
- Tell full-duplex WebSockets apart from request/response HTTP
- Follow the HTTP upgrade handshake that starts a WebSocket
- Enable WebSockets with app.UseWebSockets() and AcceptWebSocketAsync()
- Run a receive loop with ReceiveAsync / SendAsync over text & binary frames
- Close a connection cleanly with the close handshake and close codes
- Choose between raw WebSockets and SignalR for a given job
💡 Real-World Analogy
Ordinary HTTP is like posting letters: you write a request, send it, and wait for a single reply to come back; to ask again, you post a brand-new letter. A WebSocket is a phone call. After a short "Can you hear me? — Yes, go ahead" handshake, the line stays open and both sides can talk whenever they like, in either direction, without hanging up and redialling. That open, two-way line is exactly what you need for chat, live dashboards, multiplayer games, and price tickers — anywhere the server must push data the instant it happens, not only when a client asks.
HTTP vs WebSocket at a glance
| Aspect | HTTP request/response | WebSocket |
|---|---|---|
| Direction | Client asks, server answers (half-duplex) | Either side sends any time (full-duplex) |
| Connection | New connection per request (or short-lived) | One connection stays open |
| Server push | Not directly — client must poll | Built in — server pushes freely |
| Overhead per message | Full headers every time | A tiny frame header |
| Starts as | GET /path | GET + Upgrade: websocket |
| URL scheme | http:// https:// | ws:// wss:// |
A WebSocket begins life as an HTTP request — that's how it sails through firewalls and proxies — then "upgrades" into the persistent channel. Use wss:// (TLS) in production exactly as you'd use https://.
1. Full-Duplex & the Upgrade Handshake
Full-duplex means both ends can send at the same moment, like two people on a phone call talking over each other. Plain HTTP is request/response: the client speaks, the server replies, done. A WebSocket starts as a normal HTTP GET carrying an Upgrade: websocket header; the server answers 101 Switching Protocols, and from that point the same TCP connection is a raw two-way pipe. In ASP.NET Core you turn this on with app.UseWebSockets(), then complete the handshake with AcceptWebSocketAsync().
Raw WebSocket and ASP.NET Core code can't run in the in-page editor — there's no web server or browser here — so read these worked examples and run them in a real ASP.NET Core project. The runnable 🎯 Your Turn exercises further down model the same message loop in plain C# that does run here.
using System.Net.WebSockets;
using System.Text;
// Program.cs — turn on WebSocket support, then accept connections.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// 1) UseWebSockets() adds the middleware that understands the HTTP
// "Upgrade" request a browser sends to start a WebSocket.
app.UseWebSockets(new WebSocketOptions
{
// Heartbeat: send a ping every 30s so dead connections are noticed.
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
app.Map("/ws", async (HttpContext context) =>
{
// 2) Only real WebSocket upgrade requests get accepted.
// A normal browser GET is NOT a WebSocket request.
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.StatusCode = 400; // Bad Request
return;
}
// 3) AcceptWebSocketAsync completes the upgrade handshake and hands
// you a live, full-duplex WebSocket. 'using' disposes it at the end.
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
Console.WriteLine("Client connected — channel is now open both ways.");
});
app.Run();Client connected — channel is now open both ways.2. Message Framing & the Receive Loop
WebSocket data travels in frames. Each frame is labelled text (WebSocketMessageType.Text, UTF-8) or binary (WebSocketMessageType.Binary, raw bytes — files, images, protobuf). A third type, Close, is a control frame that means "I'm hanging up." Your server runs a receive loop: while the socket is open, ReceiveAsync reads the next frame into a buffer and SendAsync writes one back. A big message can span several frames — result.EndOfMessage tells you when the last chunk has arrived.
app.Map("/ws", async (HttpContext context) =>
{
if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = 400; return; }
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
// A reusable buffer to read message bytes into. Messages bigger than
// this arrive in several chunks — you stitch them back together.
var buffer = new byte[4096];
// The RECEIVE LOOP: keep reading frames while the socket is open.
while (socket.State == WebSocketState.Open)
{
WebSocketReceiveResult result = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
// The peer asked to close — run the close handshake and stop.
if (result.MessageType == WebSocketMessageType.Close)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
"Bye", CancellationToken.None);
break;
}
// Decode the text frame, then send the same text straight back.
string text = Encoding.UTF8.GetString(buffer, 0, result.Count);
Console.WriteLine($"Received: {text}");
byte[] reply = Encoding.UTF8.GetBytes($"Echo: {text}");
await socket.SendAsync(new ArraySegment<byte>(reply),
WebSocketMessageType.Text,
endOfMessage: true, // this frame is a complete message
CancellationToken.None);
}
Console.WriteLine("Loop ended — connection closed.");
});Client connected — channel is now open both ways.
Received: hello
Received: world
Loop ended — connection closed.Notice the shape: read a frame → decide what it is → send a response → loop. That single loop is the heart of every WebSocket endpoint. Now model it yourself in code that runs right here.
Your turn. A real server reads frames off the network one at a time; below, a Queue<string> stands in for those incoming frames. Loop over them and echo each one back uppercased, the way SendAsync would push a reply. Fill in the three ___ blanks, then run it.
🎯 Your turn: echo every frame, uppercased
Loop over the queue of frames and send each one back in upper case.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — model the receive loop with a Queue of "frames".
// A real server reads one frame at a time off the network; here a
// Queue<string> stands in for the incoming frames, in order.
Queue<string> incoming = new Queue<string>();
incoming.Enqueue("hello");
incoming.Enqueue("world");
incoming.Enqueue("websockets");
// 1) Loop w
...3. The Close Handshake
A WebSocket shouldn't just vanish — both sides agree to hang up. When the peer sends a Close frame, you reply with CloseAsync, passing a close code and a short reason, then break out of the receive loop. The close code is a number that says why the connection ended — 1000 for a normal, expected close, others for errors. Always handle the Close frame; if you don't, the loop reads forever and the connection leaks.
📕 Common WebSocket Close Codes
| Code | Name | Means |
|---|---|---|
| 1000 | NormalClosure | Done on purpose — the normal, clean close |
| 1001 | EndpointUnavailable | Going away (server shutting down, page navigated) |
| 1002 | ProtocolError | The other side broke the WebSocket protocol |
| 1009 | MessageTooBig | A frame exceeded the size you allow |
| 1011 | InternalServerError | The server hit an unexpected error |
In .NET these map to the WebSocketCloseStatus enum, e.g. WebSocketCloseStatus.NormalClosure is 1000.
Now handle the close signal yourself. In the queue below, one frame is the literal "close" control message. When you read it, print a closing line and break out of the loop so the frames queued after it are never processed. Fill in the two ___ blanks:
🎯 Your turn: stop the loop on a close frame
Detect the close control message and break out of the receive loop.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — handle the CLOSE control message.
// Most frames carry text. One special frame, "close", is a control
// signal: when you see it, run the close handshake and STOP reading.
Queue<string> incoming = new Queue<string>();
incoming.Enqueue("ready");
incoming.Enqueue("steady");
incoming.Enqueue("close"); // the close control frame
...🔎 Deep Dive: raw WebSockets vs SignalR
SignalR is built on top of WebSockets. It hands you automatic reconnection, fallback transports (for clients without WebSocket support), message serialisation, and "hubs" with named methods and groups — a lot of plumbing you'd otherwise write by hand.
Reach for SignalR when you're building typical .NET-to-.NET or .NET-to-browser real-time features — chat, notifications, live dashboards — and you want reconnection and groups for free.
Reach for raw WebSockets when you need full control of the wire: a custom binary protocol, a fixed message format a non-.NET client already speaks (a game client, an IoT device, an exchange feed), maximum throughput with minimal overhead, or interop where you can't assume a SignalR client.
SignalR = batteries included (reconnect, groups, fallbacks, serialisation) Raw WS = bare metal control (your protocol, your bytes, your rules)
Pro Tips
- 💡 Always honour the Close frame: when
result.MessageTypeisClose, callCloseAsyncandbreak— otherwise the loop never ends. - 💡 Buffer large messages: a single message can span many frames. Keep reading until
result.EndOfMessageistruebefore treating the bytes as one message. - 💡 Never block the receive loop: use
awaiton everyReceiveAsync/SendAsync. Synchronous waits or heavy work in the loop freeze the connection. - 💡 Set a heartbeat: a
KeepAliveIntervalping detects dead connections that TCP alone won't notice — so you free sockets that silently died. - 💡 Use
wss://in production just likehttps://— encrypted transport, and it's also more proxy-friendly. - 💡 If you don't need raw control, use SignalR — reconnection and fallbacks are tedious to get right by hand.
Common Errors (and the fix)
- Not handling the Close frame: if you ignore
WebSocketMessageType.Close, the loop keeps callingReceiveAsyncon a closing socket and the connection lingers. Always check for Close, callCloseAsync, andbreak. - No buffering for large messages: treating one
ReceiveAsyncresult as a whole message truncates anything bigger than your buffer. Loop untilresult.EndOfMessageistrue, appending each chunk. - Blocking the receive loop: calling
.Resultor.Wait(), or doing slow work inline, stalls the whole socket. Keep the loopasyncand offload heavy work elsewhere. - Missing heartbeats: a client that drops off Wi-Fi without a Close frame leaves a "zombie" connection. Set
KeepAliveIntervalso pings reveal and reap dead sockets. - "System.InvalidOperationException: The WebSocket is not connected": you called
SendAsyncafter the socket left theOpenstate. Checksocket.State == WebSocketState.Openbefore sending. - Plain GET hits your endpoint: if
IsWebSocketRequestisfalseyou must return a normal HTTP status (e.g.400) — callingAcceptWebSocketAsyncon a non-upgrade request throws.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Enable middleware | app.UseWebSockets(); | In Program.cs |
| Check it's an upgrade | context.WebSockets.IsWebSocketRequest | Reject if false |
| Accept the connection | await context.WebSockets.AcceptWebSocketAsync() | Completes the handshake |
| Read a frame | await socket.ReceiveAsync(seg, ct) | Returns type + count |
| Send a frame | await socket.SendAsync(seg, Text, true, ct) | true = end of message |
| Close cleanly | await socket.CloseAsync(NormalClosure, "Bye", ct) | Close code 1000 |
| Check state | socket.State == WebSocketState.Open | Loop condition |
Frequently Asked Questions
Q: How is a WebSocket different from just polling with HTTP?
Polling means the client asks "anything new?" over and over, each time paying full HTTP overhead and learning of changes only on the next poll. A WebSocket keeps one connection open, so the server pushes the instant something happens — lower latency and far less overhead.
Q: Do I have to abandon HTTP to use WebSockets?
No. A WebSocket starts as an HTTP request with an Upgrade header, then switches protocols on the same connection. Your app can serve normal HTTP routes and WebSocket endpoints side by side.
Q: When should I use raw WebSockets instead of SignalR?
Use raw WebSockets when you need a custom or binary protocol, must interoperate with a non-.NET client that speaks a fixed format, or want minimal overhead. Use SignalR when you want reconnection, groups, and transport fallbacks handled for you.
Q: What's a "frame" and why does message framing matter?
A frame is one labelled chunk of WebSocket data (text, binary, or a control frame like Close). A big message can be split across frames, so you must keep reading until EndOfMessage before treating the bytes as a complete message.
Q: What do the close codes mean?
1000 is a normal, intentional close. Others signal trouble — 1002 a protocol error, 1009 a message that was too big, 1011 a server error. Sending the right code tells the other side why the line dropped.
Mini-Challenge: a Ping/Pong Protocol
No blanks this time — just a brief and an outline. Write the receive loop yourself: reply "pong" to every "ping", echo any other frame unchanged, and stop the loop when you read "bye". This is exactly the shape of a real keep-alive protocol running over a WebSocket. Run it and check your output against the expected lines in the comments.
🎯 Mini-Challenge: ping → pong, echo, stop on bye
Loop over the frames: pong the pings, echo the rest, stop on bye.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 🎯 MINI-CHALLENGE: a ping/pong protocol over the receive loop
// The frames are already queued for you. Write the loop yourself:
// 1. Read each frame off the queue with Dequeue().
// 2. If the frame is "ping", reply "pong".
// 3. If the frame is "bye", print "Goodbye!" and STOP the loop.
// 4. Any other frame: echo it back unchanged ("Echo: <fr
...🎉 Lesson Complete
- ✅ WebSockets are full-duplex — both sides send any time, unlike request/response HTTP
- ✅ A connection upgrades from an HTTP
GETviaapp.UseWebSockets()+AcceptWebSocketAsync() - ✅ Data travels in frames — text, binary, or control (Close)
- ✅ The receive loop reads with
ReceiveAsyncand replies withSendAsyncwhile the socket is open - ✅ The close handshake ends a connection cleanly with a close code (
1000= normal) - ✅ Use raw WebSockets for custom/binary protocols; use SignalR for batteries-included real-time
- ✅ Next lesson: Unit Testing — write tests that prove your code works
Sign up for free to track which lessons you've completed and get learning reminders.