WebSockets
app.MapGet("/ws", async ctx =>{ if (!ctx.Request.IsWebSocketRequest()) { ctx.Response.StatusCode = StatusCodes.Status400BadRequest; return; }
await using var socket = await ctx.AcceptWebSocketAsync();
while (await socket.ReceiveAsync(ctx.RequestAborted) is { } message) await socket.SendAsync(message.Text, ctx.RequestAborted);});Receiving and sending
Section titled “Receiving and sending”ReceiveAsync returns the next complete message — fragments are reassembled for you — or null once
the peer has closed.
| Member | Notes |
|---|---|
WebSocketMessage.Type |
Text or Binary |
WebSocketMessage.Payload |
The raw bytes |
WebSocketMessage.Text |
The payload decoded as UTF-8 |
SendAsync(string) |
A text message |
SendAsync(ReadOnlyMemory<byte>) |
A binary message |
PingAsync() |
Sends a ping; nothing here waits for the pong |
CloseAsync(status, description) |
The close handshake |
IsOpen |
True until a close frame has been both sent and received |
CloseResult |
How the peer closed, once it has |
Ping/pong is handled automatically — an incoming ping is answered without the handler seeing it.
Accept options
Section titled “Accept options”await using var socket = await ctx.AcceptWebSocketAsync(new WebSocketAcceptOptions{ MaxMessageLength = 1024 * 1024, SupportedSubProtocols = { "v2.myapp", "v1.myapp" }});MaxMessageLength (4 MB by default) bounds the message assembled from fragments — a peer that keeps
sending continuation frames is otherwise an unbounded allocation. It bounds the decompressed size
too, which is what a compressed frame inside the frame limit says nothing about.
| Property | Default | Notes |
|---|---|---|
MaxMessageLength |
4 MB | Assembled from fragments, and after decompression |
SupportedSubProtocols |
empty | Server preference order wins |
EnablePerMessageDeflate |
true |
See below |
CompressionThreshold |
256 bytes | Below it, deflate makes a message bigger |
KeepAliveInterval |
30s | null switches the ping loop off |
MissedPingsBeforeClosing |
2 | Then the socket is torn down |
Sub-protocol negotiation picks by the server’s preference order, which is the side that actually
knows what it can do, and echoes the choice back. socket.SubProtocol is what was agreed.
Compression
Section titled “Compression”permessage-deflate (RFC 7692) is negotiated when the client offers it, in one specific shape: no
context takeover in either direction, which the server imposes in its answer.
With context takeover both peers keep one deflate stream alive across every message, so each message compresses against the history of the ones before it. It compresses better — and it costs a persistent zlib window per connection in each direction, roughly 300KB, held for as long as the socket lives. On a phone serving a dozen clients that is the whole memory budget, spent on the least valuable thing in the room. Here every message compresses on its own; a JSON payload still comes down to a fraction of its size and nothing has to remember anything.
A message below CompressionThreshold, or one that deflate makes larger, is sent uncompressed —
RSV1 says which one the peer got, so it costs nothing to decide per message.
EnablePerMessageDeflate = false turns the offer down.
Keepalive
Section titled “Keepalive”The server pings an idle peer every KeepAliveInterval and tears the socket down after
MissedPingsBeforeClosing unanswered pings.
This is not a nicety on a mobile network. A phone that loses signal, a laptop that sleeps, a NAT that drops an idle mapping — none of these close the socket, they go quiet. A server that never asks holds the connection object and its buffers open for a peer that has been gone for hours. It is also what keeps a tunnelled socket alive through an intermediary that reaps idle connections.
Any pong resets the counter, whoever asked for it.
Talking to every socket at once
Section titled “Talking to every socket at once”A handler owns one socket and cannot reach the others, so “tell every connected device the door just
unlocked” otherwise means the app building its own list, its own locking and its own dead-socket
cleanup. IWebSocketRegistry is those three things.
builder.AddWebSocketRegistry();
app.MapGet("/ws", async ctx =>{ await using var tracked = await ctx.AcceptTrackedWebSocketAsync(cancellationToken: ctx.RequestAborted); tracked.JoinGroup("kitchen");
while (await tracked.ReceiveAsync(ctx.RequestAborted) is { } message) await registry.SendToGroupAsync("kitchen", message.Text, ctx.RequestAborted);});await registry.BroadcastAsync("the door unlocked"); // returns how many were reachedawait registry.BroadcastAsync(bytes, x => x.InGroup("kitchen"));await registry.SendToGroupAsync("kitchen", "oven is hot");await registry.SendToUserAsync("ada", "your build finished"); // their phone and their tabletawait registry.SendToAsync(id, "just you");await registry.CloseAllAsync(); // shutting down, or signing someone out- A broadcast never throws because one peer is dead: that socket is dropped from the registry and the returned count is one lower.
- Sends run concurrently, so one stalled client does not hold up the broadcast to everyone behind it.
- Disposing the
TrackedWebSocketuntracks and closes it, so there is no cleanup pass to write. AcceptTrackedWebSocketAsyncrecordsctx.User.Identity?.Name, which is whatSendToUserAsyncmatches on.
The handshake
Section titled “The handshake”IsWebSocketRequest() checks the method, the Connection: Upgrade token, Upgrade: websocket and
the presence of Sec-WebSocket-Key. It reads Connection as the comma-separated list it is, because
browsers send keep-alive, Upgrade and a plain equality check misses the upgrade on most real
requests.
Version 13 is the only one RFC 6455 defines. Anything else is answered with 426 and a
Sec-WebSocket-Version: 13 header telling the client which one to use.
Authorization
Section titled “Authorization”The upgrade is an ordinary GET on an ordinary route, so everything that applies to a route applies
here:
app.MapGet("/ws", Handler).RequireAuthorization();A browser’s WebSocket constructor cannot set headers, so a token normally travels in the query
string or in a cookie — cookie authentication is the usual answer for
a browser client, and a JWT in the query string for anything else.
Limits
Section titled “Limits”permessage-deflateis the only extension negotiated.- WebSockets need a real connection to take over, so they work over HTTP/1.1 and over a
tunnel that forwards raw connections — but not through Azure Relay’s
Httpmode, which buffers responses. See Azure Relay.
Server-Sent Events
Section titled “Server-Sent Events”If the traffic only goes server → client, Server-Sent Events are simpler, survive proxies better, and reconnect on their own.


