WebSockets in .NET
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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();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.");
});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.
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 while there are still frames waiting to be read.
while (incoming.___) // 👉 Count > 0 (frames remain)
{
// 2) Take the next frame off the front of the queue.
string frame = incoming.___; // 👉 Dequeue() (read one frame)
// 3) Echo it back, UPPERCASED, like SendAsync would.
string reply = frame.___; // 👉 ToUpper() (shout it back)
Console.WriteLine($"Echo: {reply}");
}
// ✅ Expected output:
// Echo: HELLO
// Echo: WORLD
// Echo: WEBSOCKETS
}
}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:
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
incoming.Enqueue("ignored"); // arrives AFTER close — never read
while (incoming.Count > 0)
{
string frame = incoming.Dequeue();
// 1) If this frame is the close signal, finish and leave the loop.
if (frame == ___) // 👉 "close" (the control message)
{
Console.WriteLine("Closing connection (NormalClosure).");
___; // 👉 break; (stop the receive loop)
}
// 2) Otherwise treat it as a normal text frame and echo it.
Console.WriteLine($"Echo: {frame}");
}
Console.WriteLine("Loop ended.");
// ✅ Expected output:
// Echo: ready
// Echo: steady
// Closing connection (NormalClosure).
// Loop ended.
}
}🔎 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.MessageType is Close, call CloseAsync and break — otherwise the loop never ends.
- 💡 Buffer large messages: a single message can span many frames. Keep reading until result.EndOfMessage is true before treating the bytes as one message.
- 💡 Never block the receive loop: use await on every ReceiveAsync/SendAsync. Synchronous waits or heavy work in the loop freeze the connection.
- 💡 Set a heartbeat: a KeepAliveInterval ping detects dead connections that TCP alone won't notice — so you free sockets that silently died.
- 💡 Use wss:// in production just like https:// — 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 calling ReceiveAsync on a closing socket and the connection lingers. Always check for Close, call CloseAsync, and break.
- No buffering for large messages: treating one ReceiveAsync result as a whole message truncates anything bigger than your buffer. Loop until result.EndOfMessage is true, appending each chunk.
- Blocking the receive loop: calling .Result or .Wait(), or doing slow work inline, stalls the whole socket. Keep the loop async and offload heavy work elsewhere.
- Missing heartbeats: a client that drops off Wi-Fi without a Close frame leaves a "zombie" connection. Set KeepAliveInterval so pings reveal and reap dead sockets.
- "System.InvalidOperationException: The WebSocket is not connected": you called SendAsync after the socket left the Open state. Check socket.State == WebSocketState.Open before sending.
- Plain GET hits your endpoint: if IsWebSocketRequest is false you must return a normal HTTP status (e.g. 400) — calling AcceptWebSocketAsync on 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.
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: <frame>").
//
// ✅ Expected output:
// pong
// Echo: status
// pong
// Goodbye!
Queue<string> incoming = new Queue<string>();
incoming.Enqueue("ping");
incoming.Enqueue("status");
incoming.Enqueue("ping");
incoming.Enqueue("bye");
incoming.Enqueue("never read");
// your loop here
}
}🎉 Lesson Complete
- ✅ WebSockets are full-duplex — both sides send any time, unlike request/response HTTP
- ✅ A connection upgrades from an HTTP GET via app.UseWebSockets() + AcceptWebSocketAsync()
- ✅ Data travels in frames — text, binary, or control (Close)
- ✅ The receive loop reads with ReceiveAsync and replies with SendAsync while 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
Practice quiz
What does "full-duplex" mean for a WebSocket connection?
- Only the client may send messages
- Messages must alternate strictly request then response
- Both ends can send at the same time, in either direction
- The connection closes after each message
Answer: Both ends can send at the same time, in either direction. Full-duplex means both sides can send whenever they like, like two people on a phone call — unlike HTTP's request/response model.
How does a WebSocket connection begin?
- As a normal HTTP GET carrying an Upgrade: websocket header
- As a UDP datagram
- As a raw TCP socket with no HTTP
- As an FTP transfer
Answer: As a normal HTTP GET carrying an Upgrade: websocket header. A WebSocket starts as an HTTP GET with an Upgrade: websocket header; the server replies 101 Switching Protocols and the same connection becomes a two-way pipe.
In ASP.NET Core, which call completes the upgrade handshake and returns a live WebSocket?
- app.UseWebSockets()
- context.Response.WriteAsync()
- socket.SendAsync()
- context.WebSockets.AcceptWebSocketAsync()
Answer: context.WebSockets.AcceptWebSocketAsync(). UseWebSockets() enables the middleware, but AcceptWebSocketAsync() completes the handshake and hands you the live, full-duplex WebSocket.
Before accepting, what should you check on an incoming request?
- context.Request.IsHttps
- context.WebSockets.IsWebSocketRequest
- socket.State == Open
- result.EndOfMessage
Answer: context.WebSockets.IsWebSocketRequest. Check IsWebSocketRequest; a plain GET is not an upgrade request, so you should return a normal status like 400 rather than calling AcceptWebSocketAsync.
How does WebSocket data travel?
- In frames labelled text, binary, or control (like Close)
- As complete HTTP responses
- Only as UTF-8 strings
- As XML documents
Answer: In frames labelled text, binary, or control (like Close). WebSocket data travels in frames. Each is labelled text (UTF-8), binary (raw bytes), or a control frame such as Close.
In the receive loop, which method reads the next incoming frame?
- socket.SendAsync
- socket.CloseAsync
- socket.ReceiveAsync
- socket.AcceptAsync
Answer: socket.ReceiveAsync. ReceiveAsync reads the next frame into a buffer; SendAsync writes a frame back. The loop runs while socket.State is Open.
What tells you a large message has fully arrived across multiple frames?
- result.MessageType
- result.EndOfMessage is true
- result.Count is zero
- socket.State is Closed
Answer: result.EndOfMessage is true. A big message can span several frames; result.EndOfMessage being true signals the final chunk has arrived so you can treat the bytes as one message.
When the peer sends a Close frame, what should the server do?
- Ignore it and keep reading
- Immediately throw an exception
- Send the frame back unchanged
- Reply with CloseAsync and break out of the receive loop
Answer: Reply with CloseAsync and break out of the receive loop. Honour the Close frame: call CloseAsync with a close code and reason, then break the loop. Ignoring it leaks the connection.
What does WebSocket close code 1000 (NormalClosure) indicate?
- A protocol error
- A normal, intentional clean close
- A message that was too big
- An internal server error
Answer: A normal, intentional clean close. 1000 (NormalClosure) means the connection was closed on purpose in the normal, clean way. Codes like 1002, 1009 and 1011 signal errors.
When should you reach for raw WebSockets instead of SignalR?
- When you want automatic reconnection and groups for free
- For ordinary .NET-to-browser chat
- When you need a custom or binary protocol, or interop with a non-.NET client
- When you want transport fallbacks handled for you
Answer: When you need a custom or binary protocol, or interop with a non-.NET client. SignalR gives reconnection, groups, and fallbacks for typical real-time features. Raw WebSockets are for full wire control: custom/binary protocols, non-.NET clients, or minimal overhead.
Continue this course
- Previous: Real-Time Communication with SignalR
- Next: Unit Testing Mastery (xUnit, NUnit, MSTest) — Write clean, maintainable tests with xUnit, NUnit, and assertions
- Quick reference: C# cheat sheet