AlgoMaster Logo

WebSockets

Medium Priority23 min readUpdated June 6, 2026

A WebSocket is a single long-lived TCP connection that lets a server push data to a client and the client send data back, both at any time, without the request-response shape that HTTP forces on you. The protocol starts as an HTTP request, gets upgraded to its own framed message protocol, and then stays open until one side closes it. This lesson covers why WebSockets exist, the upgrade handshake, the frame model, the C# client API (System.Net.WebSockets.ClientWebSocket), the server side in ASP.NET Core, the patterns for sending and receiving messages cleanly, the close handshake, and the cases where WebSockets are the wrong tool.

Why WebSockets Exist

HTTP is request-response. A client asks for something, the server answers, the connection is reused for the next request, and that's the whole conversation. That model is fine for fetching a product page or submitting a checkout form, but it falls apart when the server has news the client needs to know about as it happens. A live order status feed is the canonical example: an order moves from Placed to Confirmed to Preparing to Shipped over the course of half an hour, and the customer's browser tab needs to show each transition the moment it happens.

Three older approaches tried to solve this within the request-response shape.

Polling. The browser asks "any updates?" every few seconds. The server answers "no" most of the time. The client wastes bandwidth, the server wastes CPU, and the update still arrives up to several seconds late. For a flash sale stock counter that changes ten times a second, polling is hopeless.

Long polling. The browser asks for an update and the server holds the request open until something happens, then returns the update and the browser immediately re-asks. The update latency is good, but each cycle is a new HTTP request with a new set of headers, and any proxy in the middle can time out the hanging request. The server also has to deal with each client holding a thread or async continuation waiting.

Server-Sent Events (SSE). A one-way streaming HTTP response from server to client. The server writes events into a long-lived response body, the browser parses them out. Latency is good and the API is simple, but the channel is one-way. Anything the client wants to say goes through a separate HTTP request.

WebSockets fix all three. One TCP connection, full-duplex (both directions at once), low overhead per message (a few bytes of framing instead of a full HTTP header set), and the connection stays open for as long as both sides want it. For a chat feature, a live order tracker, a multiplayer game, or a flash sale stock ticker, this is exactly the shape you want.

The trade-off is that WebSockets aren't HTTP anymore once the upgrade finishes. They don't go through HTTP caches, they don't follow HTTP redirects, and the load balancer in front of your service has to be configured to understand them. They also need a server that can hold tens of thousands of connections open without melting, which is a different operational shape from "one short request, one short response."

The HTTP Upgrade Handshake

Every WebSocket starts life as an HTTP request. That's deliberate: it means a WebSocket endpoint can live on port 80 or 443 alongside normal HTTP traffic, pass through the same firewalls, and use the same TLS configuration. The "upgrade" is how the client and server agree to stop speaking HTTP and start speaking WebSocket frames on the same TCP connection.

The client sends a regular GET request with two special headers:

Connection: Upgrade and Upgrade: websocket together tell the server "I want to switch protocols." Sec-WebSocket-Key is a random base64-encoded value the client generates per connection. Sec-WebSocket-Version: 13 is the current protocol version, defined by RFC 6455.

The server, if it agrees, responds with status 101 Switching Protocols:

The Sec-WebSocket-Accept header is computed by taking the client's Sec-WebSocket-Key, appending a fixed magic string from the RFC, hashing the combined value with SHA-1, and base64-encoding the result. The client verifies it matches before treating the connection as upgraded. That check exists to make sure the response came from a real WebSocket server, not from a confused HTTP cache or proxy.

Once the 101 response is on the wire, both sides stop speaking HTTP. The same TCP connection now carries WebSocket frames, and the conversation can flow in either direction independently.

The handshake is the only HTTP that ever happens. After the 101, the TCP socket carries binary WebSocket frames forever, in both directions, until one side initiates the close handshake.

In C#, you don't write any of this by hand. ClientWebSocket.ConnectAsync does the upgrade on the client side, and HttpContext.WebSockets.AcceptWebSocketAsync does it on the server side. Both return a WebSocket object that hides the framing details and gives you SendAsync / ReceiveAsync instead.

The Frame Model

Once the upgrade is done, every message you send becomes one or more frames on the wire. A frame has a small binary header (a few bytes, depending on the payload size) followed by the payload bytes. You almost never deal with frames directly in C# code, but knowing what they are explains why the API looks the way it does.

There are two frame types you'll send: text and binary. Text frames carry UTF-8 encoded strings, which is what most JSON-based APIs use. Binary frames carry raw bytes, which is what you'd use for images, compressed payloads, or a custom binary protocol. The receiver knows which kind it got because the frame header carries the type.

There are also control frames, which the runtime handles for you: close, ping, and pong. A ping is a keepalive: one side sends it, the other side must respond with a pong. The .NET WebSocket implementation sends pings on its own when you've configured a KeepAliveInterval, so you typically don't write ping-pong code yourself.

A single logical message can span multiple frames. The protocol calls this fragmentation. The sender flips a FIN bit on the last frame of a message; everything before it is a continuation. Why fragment? It lets the sender start emitting a large payload before it knows the total size, which matters for streaming output. In C#, SendAsync takes a parameter called endOfMessage: pass true for "this is the whole message" or false for "more frames coming for this message."

On the receive side, ReceiveAsync returns a WebSocketReceiveResult after each frame. Its EndOfMessage property tells you whether the current frame finishes the logical message or whether you need to keep calling ReceiveAsync and concatenating bytes until you see EndOfMessage == true. That's the source of a lot of beginner bugs: people treat one ReceiveAsync call as one message, when in reality one message might be many calls.

Frame typeCarriesC# representation
TextUTF-8 encoded stringWebSocketMessageType.Text
BinaryRaw bytesWebSocketMessageType.Binary
CloseClose code + reasonHandled via WebSocketCloseStatus
Ping / PongKeepaliveHandled by the runtime

The receive loop pattern, in plain English, is: call ReceiveAsync into a buffer, check the message type (text, binary, or close), check EndOfMessage, accumulate bytes until you see a final frame, then process the complete message. Every WebSocket app you'll write follows this shape on both ends.

WebSocket frame headers are 2-14 bytes, much smaller than HTTP's typical 200-500 bytes of headers per request. That's the per-message bandwidth savings that make WebSockets outperform polling.

Client Side: ClientWebSocket

The .NET BCL ships System.Net.WebSockets.ClientWebSocket, the standard client. It lives in System.Net.WebSockets, takes a Uri starting with ws:// (plain) or wss:// (TLS), and exposes three methods you'll use constantly: ConnectAsync, SendAsync, and ReceiveAsync, plus CloseAsync for the close handshake.

A minimal connect-and-receive example looks like this:

Output (assuming the server pushes a single status message):

A few things in that snippet matter. ClientWebSocket is IDisposable; the using var declaration makes sure the underlying socket gets cleaned up. ConnectAsync performs the HTTP upgrade handshake. After it returns, client.State is WebSocketState.Open and you can start sending and receiving. SendAsync takes an ArraySegment<byte> so you can send only part of a buffer if you want, plus the message type (text vs binary) and the endOfMessage flag.

The naive single-ReceiveAsync pattern in that example works only because the server's message was small enough to fit in one frame and small enough to fit in the buffer. Real code needs a loop, which is the next section.

Each ClientWebSocket holds a TCP connection, two buffers (send and receive), and some bookkeeping state. The default send buffer is 16 KB and the default receive buffer is 4 KB. For a server holding 10,000 open connections, that's about 200 MB of buffer memory alone, before you count the OS-level socket buffers.

A more realistic receive loop reads until it sees an end-of-message frame, accumulates the bytes, and processes the complete message:

The loop reads as many frames as it takes to complete a single message, writes the bytes into a MemoryStream, and returns the decoded string when the final frame arrives. If the server sends a close frame instead of text, the method completes the close handshake and returns an empty string so the caller knows to stop.

The same loop works for binary messages: swap the Encoding.UTF8.GetString for whatever decoding the binary format needs.

Server Side: ASP.NET Core

On the server side, you don't open a listening socket and write the handshake yourself. ASP.NET Core has built-in WebSocket middleware that detects upgrade requests and gives you back a WebSocket object inside a request handler. The minimal hosting model (Program.cs with WebApplication.CreateBuilder) makes this short:

Output (server console):

The pieces line up with the client example. app.UseWebSockets() plugs the WebSocket middleware into the pipeline; it's what notices the Upgrade: websocket header and prepares the connection for upgrade. The endpoint handler checks context.WebSockets.IsWebSocketRequest to confirm the incoming request is actually trying to upgrade. If it's not (someone hit the URL in a browser address bar, say), we return a 400 and bail. If it is, AcceptWebSocketAsync completes the 101 handshake and returns a WebSocket we can read from and write to.

From there, the server-side WebSocket has the same SendAsync and ReceiveAsync methods as the client. The protocol is symmetric: once the upgrade is done, neither side cares which one initiated the connection.

The example above is a server-push pattern: the server sends three messages and the client doesn't say anything back. For a server that needs to receive messages from the client (subscribe requests, ping replies, chat messages), the server runs a receive loop with the same shape as the client's loop. The next section covers that loop pattern in depth, since both ends use it.

ASP.NET Core's WebSocket middleware uses Microsoft.AspNetCore.WebSockets internally, built on System.Net.WebSockets.WebSocketProtocol. Each connection ties up an async continuation chain but no dedicated thread, so a single server can hold tens of thousands of open WebSockets if the per-connection work is light.

The "per-connection work" caveat matters. If your receive loop allocates a new buffer on every iteration, or holds a lock while writing, the per-connection memory and contention can dominate. Pool your buffers (ArrayPool<byte>.Shared.Rent and Return), and avoid shared locks in the hot path.

Send and Receive Loops

The two patterns you'll write most often are the same on the client and the server: a loop that reads complete messages, and a method that sends complete messages. Building them as small reusable methods keeps the rest of your code clean.

Here's a complete server that runs a receive loop, treats each incoming text message as a subscribe command, and pushes a stream of status updates back:

The handler runs as long as the socket stays open. Every iteration reads one complete logical message (ReceiveTextMessageAsync handles the multi-frame case), prints it, and dispatches based on the contents. The dispatch logic here is just "subscribe X means stream status for order X," but in a real app, it would route to a domain service that knows what to do with each command.

SendTextMessageAsync is the symmetric helper. It takes a string, UTF-8 encodes it, and sends it as a single text frame with endOfMessage: true. For large messages, you'd add a parameter for the fragment size and loop over the input.

context.RequestAborted is the cancellation token ASP.NET Core fires when the client disconnects. Passing it into the receive loop and the streaming logic means the server stops sending the moment the client drops, instead of pushing data into a closed socket. The Exception Handling section covers OperationCanceledException in depth; in this context, catching it lets the connection unwind cleanly.

On the client side, the equivalent receive loop runs in its own background task so the main thread can do other work (drive a UI, send periodic pings, accept user input). A complete client looks like this:

Output (client console, with the server above):

The client connects, fires off a single subscribe message, then sits in its receive loop printing each pushed update as it arrives. The server closes the socket after the last Shipped update, the loop sees a close frame, acknowledges it, and exits. The final state of the socket is Closed, and the program ends.

The flow of one full conversation, from open to close, is:

The handshake happens once at the start. After that, both sides are in their own loops sending and receiving frames independently. The close handshake at the end is symmetric: whoever initiates sends a close frame, the other side acknowledges, and both close their TCP socket.

Closing Cleanly

WebSockets have a proper close handshake. One side sends a close frame with a status code and an optional reason string. The other side responds with its own close frame. Only after both close frames have been exchanged does the underlying TCP socket actually shut down. Treating "close" as a one-shot operation, the way you'd treat a TCP socket, leaves connections in half-closed states and trips up your reconnect logic.

The C# API has two methods for this. CloseAsync is the polite version: it sends a close frame, then waits for the peer's close frame before returning. CloseOutputAsync is the half-close version: it sends a close frame and returns immediately, leaving the connection in CloseSent state, where you can still receive but can no longer send. You'll almost always want CloseAsync unless you're implementing an unusual shutdown pattern.

The WebSocketCloseStatus enum carries the standard close codes from the RFC. The ones you'll actually use:

CodeEnum valueMeaning
1000NormalClosureClean, graceful close. Both sides agreed we're done.
1001EndpointUnavailableThe server is shutting down or the page is unloading.
1002ProtocolErrorThe peer sent malformed framing. Bail out.
1003InvalidMessageTypeThe peer sent a type we can't handle (text when we expected binary, say).
1008PolicyViolationThe peer violated an application rule (auth failure, rate limit).
1011InternalServerErrorWe crashed and can't recover this connection.

A graceful client-initiated close looks like this:

After CloseAsync returns, socket.State is WebSocketState.Closed. The connection is fully shut down and the underlying TCP socket has been disposed.

A server initiating a close is mirror-image. The server calls CloseAsync from its handler, the client's ReceiveAsync returns a result with MessageType == WebSocketMessageType.Close, and the client acknowledges by calling its own CloseAsync. The receive loop in the previous section handles this case: when it sees a close message type, it responds with CloseAsync and returns null so the caller knows the connection is done.

If either side closes the underlying TCP socket without sending a close frame (a network drop, a hard process kill, a WebSocketException), the other side sees an exception out of ReceiveAsync instead of a clean close. Real code wraps the receive loop in a try/catch:

WebSocketException is the family of exceptions for protocol-level failures: malformed frames, unexpected closures, TLS errors during the upgrade. The Exception Handling section covers exception handling patterns in depth; the takeaway here is that any production WebSocket code needs to treat WebSocketException as a recoverable signal to clean up and (often) try to reconnect.

A connection that closes without the close handshake leaves the operating system holding the TCP socket in TIME_WAIT for up to two minutes, which counts toward your ephemeral port limit. On a busy server that reconnects clients frequently, a flood of unclean closes can exhaust the port range and start refusing new connections.

Common Pitfalls

A handful of WebSocket bugs come up over and over. Each one has the same root cause: treating the API as if it were higher-level than it actually is.

Treating one `ReceiveAsync` call as one message. Already covered, but the most common mistake by a wide margin. Always loop until EndOfMessage == true before processing the bytes as a logical message. The fix is the receive helper shown in earlier sections.

Receive buffer too small for a single frame. ReceiveAsync writes up to buffer.Length bytes per call. If the incoming frame is larger than the buffer, you get a result with EndOfMessage == false even though the sender intended this to be a single-frame message. That's not a bug, your loop should still concatenate, but it surprises people who didn't expect to enter the multi-frame path for what they thought was a small message. A 4 KB buffer is a sensible default for JSON payloads; bump it up if you're transferring binary blobs.

Sharing a `WebSocket` across concurrent send calls. The WebSocket class is not safe for concurrent sends. Two SendAsync calls at the same time on the same socket can interleave frames and corrupt the stream. If you need to send from multiple producers, serialize them through a Channel<T> or a SemaphoreSlim. Receives are similarly single-consumer: only one task should be calling ReceiveAsync on a given socket.

Forgetting `endOfMessage: true`. Sending a frame with endOfMessage: false tells the peer "more frames coming for this message." If your code never sends the closing frame, the peer's receive loop hangs forever, accumulating bytes that never get processed. Default to true and only set false when you're explicitly fragmenting.

No keepalive on a long-lived connection. Connections that sit idle for minutes are perfect targets for middleboxes (NAT routers, load balancers, corporate proxies) that close idle TCP sockets to free state. The .NET implementation will send pings automatically if you set KeepAliveInterval on ClientWebSocket.Options or in WebSocketOptions on the server. 30 seconds is a reasonable default; shorter than that wastes bandwidth, much longer risks intermediary timeouts.

Blocking the receive loop with synchronous work. If processing a received message takes 200 ms of CPU time, the receive loop can't read the next frame for those 200 ms, and the sender's frames pile up in the OS buffer. Offload non-trivial message handling to a Channel<T> consumed by a separate task, and keep the receive loop focused on reading bytes.

Not handling `ClientWebSocket.State` correctly. After a WebSocketException or a network drop, the socket lands in Aborted or CloseReceived instead of Closed. Always check socket.State before calling CloseAsync, and treat any state other than Open as "this connection is done, build a new one."

Closing without acknowledgement. Calling CloseOutputAsync and immediately disposing the socket sends your close frame but doesn't wait for the peer's reply. The peer sees an abrupt connection close and may log it as an error. Use CloseAsync for normal shutdown unless you have a specific reason to half-close.

PitfallSymptomFix
One ReceiveAsync per messagePartial / truncated messagesLoop until EndOfMessage == true
Small receive bufferSurprisingly enters multi-frame loopSensible buffer size (4 KB or more), still loop
Concurrent sendsInterleaved frames, corrupted streamSerialize sends via Channel<T> or SemaphoreSlim
Missing endOfMessage: truePeer hangs waiting for more framesDefault to true, fragment only on purpose
No keepaliveConnection drops after idle period with no notificationSet KeepAliveInterval to 30 seconds
Slow message handlerSender's frames back up in OS bufferMove handling off the receive loop with a channel
Using a socket after AbortedInvalidOperationException on CloseAsyncCheck State before each call
Half-close on shutdownPeer logs unexpected disconnectUse CloseAsync for normal shutdown

Most of these are one-line fixes once you know to look for them. The bigger lesson is that WebSocket is a thin wrapper around a protocol-aware TCP socket. The runtime handles framing, pings, and the close handshake, but everything else (message reassembly, concurrency, error recovery) is your code to write.

When Not to Use WebSockets

WebSockets solve a specific problem: server-driven updates with low latency on a persistent connection. For everything else, simpler protocols win.

Pure request-response APIs. A REST endpoint that returns a product or processes a checkout has no need for a persistent connection. The HTTP request-response cycle handles caching, retries, and load balancing out of the box. WebSockets force you to reimplement all of that yourself if you put a REST-shaped API on top of one. Stick with HttpClient and ordinary HTTP for these (the _HttpClient_ lesson covers the client side).

Server-to-client streams with no client-to-server traffic. A live order status feed, a stock ticker, a notification stream: if the client never says anything except "subscribe," Server-Sent Events (SSE) is simpler. SSE is plain HTTP with a streaming response body, so it works with any HTTP proxy, caches in standard ways, and is half the code. WebSockets pay off when the client also needs to send messages back.

Low-frequency updates. If new data arrives every 30 seconds, polling every 30 seconds (or long-polling with a one-minute hang) is fine and dramatically simpler operationally. Holding a WebSocket open for thousands of clients just to push one message every half-minute is wasted infrastructure.

Communication over a short-lived process. WebSockets are designed for connections that last minutes to hours. A serverless function with a 30-second timeout, a CLI tool that runs and exits, or a job that needs one piece of data and is done: just use HTTP. The connection setup cost of the upgrade handshake is meaningful when the lifetime of the connection is short.

Strict request ordering with retries. WebSockets are message-oriented, not request-oriented. If a message gets lost (because the connection dropped mid-send), there's no built-in retry. HTTP has retries, idempotency keys, and status codes that tell you whether your request actually got through. For e-commerce checkouts, payments, or anything where exactly-once matters, the request-response shape with HTTP semantics is what you want.

The short version is summarized in the table below, including how WebSockets compare with the two main alternatives:

ApproachDirectionPer-message overheadLatencyBest for
PollingClient to server (request-response)Full HTTP headers per pollSeconds (depends on poll interval)Low-frequency updates, simple infrastructure
Server-Sent Events (SSE)Server to client onlySmall (event framing on a long-lived HTTP response)Sub-secondOne-way push streams, browser notifications
WebSocketsBoth directions, full-duplexFew bytes per frameSub-secondChat, multiplayer games, live order tracking with client commands

The decision rule: if the client never needs to send anything except "subscribe," prefer SSE for the simpler operational story. If the client and server need to talk back and forth in real time, use WebSockets. If updates are infrequent or you can tolerate polling-level latency, neither is worth the complexity.

WebSockets also sit on top of TCP, so they inherit TCP's at-least-once delivery within a connection but no guarantees across disconnects. If your application needs delivery guarantees, you need to layer a sequence number and an acknowledgement scheme on top of the WebSocket, or use a message broker designed for the job (Kafka, RabbitMQ) and have the WebSocket be a thin façade over the broker.

Quiz

WebSockets Quiz

10 quizzes