Skip to main content

    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

    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.

    Worked example: enable WebSockets & accept the upgrade
    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();
    Output
    Client connected — channel is now open both ways.
    Run this in your own terminal or editor to see it work.

    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.

    Worked example: the receive loop (echo server)
    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.");
    });
    Output
    Client connected — channel is now open both ways.
    Received: hello
    Received: world
    Loop ended — connection closed.
    Run this in your own terminal or editor to see it work.

    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.

    Try it Yourself »
    C#
    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

    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:

    🎯 Your turn: stop the loop on a close frame

    Detect the close control message and break out of the receive loop.

    Try it Yourself »
    C#
    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.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

    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.

    🎯 Mini-Challenge: ping → pong, echo, stop on bye

    Loop over the frames: pong the pings, echo the rest, stop on bye.

    Try it Yourself »
    C#
    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 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
    • 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service