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

💡 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

AspectHTTP request/responseWebSocket
DirectionClient asks, server answers (half-duplex)Either side sends any time (full-duplex)
ConnectionNew connection per request (or short-lived)One connection stays open
Server pushNot directly — client must pollBuilt in — server pushes freely
Overhead per messageFull headers every timeA tiny frame header
Starts asGET /pathGET + Upgrade: websocket
URL schemehttp:// 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

CodeNameMeans
1000NormalClosureDone on purpose — the normal, clean close
1001EndpointUnavailableGoing away (server shutting down, page navigated)
1002ProtocolErrorThe other side broke the WebSocket protocol
1009MessageTooBigA frame exceeded the size you allow
1011InternalServerErrorThe 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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Enable middlewareapp.UseWebSockets();In Program.cs
Check it's an upgradecontext.WebSockets.IsWebSocketRequestReject if false
Accept the connectionawait context.WebSockets.AcceptWebSocketAsync()Completes the handshake
Read a frameawait socket.ReceiveAsync(seg, ct)Returns type + count
Send a frameawait socket.SendAsync(seg, Text, true, ct)true = end of message
Close cleanlyawait socket.CloseAsync(NormalClosure, "Bye", ct)Close code 1000
Check statesocket.State == WebSocketState.OpenLoop 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

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