WebSockets & SSE
Key Points
- WebSocket: full-duplex; binary or text; one TCP connection. Use for bidirectional real-time.
- Server-Sent Events (SSE): server→client only; HTTP; auto-reconnect built-in; text/event-stream content type. Use for one-way push (notifications, progress, logs).
- In .NET: ASP.NET Core has both. Use raw when SignalR is too heavy or you need protocol control. SignalR sits on top of these.
- WebSocket trade-offs: persistent connection state; harder to scale; firewall edge cases. SSE simpler — just HTTP.
- Common patterns: SSE for log streaming, dashboards. WebSocket for chat, multiplayer, collaborative editing.
Concepts (deep dive)
WebSocket
app.UseWebSockets();
app.Map("/ws", async ctx =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = 400;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var buffer = new byte[4096];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer, ctx.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close) break;
var text = Encoding.UTF8.GetString(buffer, 0, result.Count);
var echo = Encoding.UTF8.GetBytes($"echo: {text}");
await ws.SendAsync(echo, WebSocketMessageType.Text, true, ctx.RequestAborted);
}
});
Low-level. You handle framing, state, reconnect, scaling.
SSE (Server-Sent Events)
app.MapGet("/events", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.Headers.Append("Content-Type", "text/event-stream");
ctx.Response.Headers.Append("Cache-Control", "no-cache");
ctx.Response.Headers.Append("Connection", "keep-alive");
int i = 0;
while (!ct.IsCancellationRequested)
{
await ctx.Response.WriteAsync($"data: {{\"count\": {i++}}}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
await Task.Delay(1000, ct);
}
});
Built-in browser auto-reconnect. Simple.
When SSE
- One-way server → client.
- Text-based payloads.
- Don't need binary.
- Want HTTP-friendly (no upgrade dance).
- Want auto-reconnect for free.
Examples: log streaming, progress updates, notifications, dashboards.
When WebSocket
- Bidirectional.
- Binary payloads.
- Real-time games, multiplayer.
- Collaborative editing.
When SignalR (instead of raw)
- Need transport fallback.
- Multi-server scaling (backplane).
- Auth integration.
- High-level abstraction.
Most real-time .NET apps should use SignalR over raw.
Format: text/event-stream
data:— payload (multi-line OK).event:— type foraddEventListener('tick', ...).id:— last event ID; client sends inLast-Event-IDheader on reconnect.retry:— milliseconds to wait before reconnect.- Two newlines (
\n\n) terminates the event.
Reconnect handling
Browser EventSource: built-in. Sends Last-Event-ID header — server can resume from that ID.
Headers in WebSocket / SSE
WebSocket handshake: HTTP upgrade. Headers OK.
SSE: standard HTTP request. Auth via cookies (browser auto-attaches) or query string (browsers can't set Authorization on EventSource — workaround).
CORS
builder.Services.AddCors(o => o.AddPolicy("rt", p => p.WithOrigins("https://app").AllowCredentials()));
app.UseCors("rt");
Both WS and SSE subject to CORS.
Backpressure
WebSocket: SendAsync awaits — natural backpressure. But if you push faster than client reads, the OS buffer fills.
SSE: write to Response.Body. Unbuffered if you Flush. Needs your own throttling.
Scaling
WebSocket multi-instance
WebSocket connections are sticky to one server. Multi-instance needs:
- Sticky sessions at LB — same client → same server.
- Backplane (Redis pub/sub, Service Bus) — server A publishes; servers B, C broadcast to their connections.
SSE multi-instance
Same issue. Sticky LB or pub/sub backplane.
Heartbeats / keep-alive
Idle WebSocket connections get killed by intermediaries (load balancers, NATs).
// Send ping every 30s
await ws.SendAsync(Array.Empty<byte>(), WebSocketMessageType.Binary, true, ct);
For SSE: send a comment line periodically:
Authentication
WebSocket
Browsers can't set Authorization header on WS handshake. Pass token in query string and read in JwtBearer's OnMessageReceived (same pattern as SignalR).
SSE
Same problem — EventSource can't set Authorization. Workarounds: - Cookie auth (browsers attach). - Token in URL query. - Custom JS using fetch + ReadableStream (then you can set headers).
CancellationToken
Always honor ctx.RequestAborted — fires when client disconnects. Stops your loop.
Buffer sizes
Default is 4KB. Larger for binary-heavy.
Compression
WebSocket has permessage-deflate. Enable in ASP.NET:
Modern browsers negotiate it.
Multiple WebSocket subprotocols
var ws = await ctx.WebSockets.AcceptWebSocketAsync(new WebSocketAcceptContext
{
SubProtocol = "graphql-ws" // negotiated subprotocol
});
For higher-level protocols on top (GraphQL Subscriptions, MQTT-over-WS).
Stream wrapping for SSE convenience
// .NET 8+ Helper:
return Results.ServerSentEvents(/* IAsyncEnumerable<...> */);
// (Hypothetical/some libraries)
Or use third-party (Microsoft.AspNetCore.Mvc.Formatters.Text).
IAsyncEnumerable<T> for SSE
app.MapGet("/events", async (CancellationToken ct) =>
{
return Results.Ok(StreamData(ct)); // streamed
});
async IAsyncEnumerable<Event> StreamData([EnumeratorCancellation] CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
yield return await NextEvent(ct);
}
}
Combined with proper response writing, clean SSE.
Code: correct vs wrong
❌ Wrong: ignoring CancellationToken
✅ Correct: honor
❌ Wrong: long polling reinvented
✅ Correct: real SSE
(See setup above)
❌ Wrong: raw WebSocket when SignalR fits
Manual reconnect, scaling, framing. SignalR handles all this.
✅ Correct: SignalR
For 95% of scenarios.
Design patterns for this topic
Pattern 1 — "SSE for one-way push"
- Intent: simpler than WS; auto-reconnect.
Pattern 2 — "WS for bidirectional"
- Intent: chat, games.
Pattern 3 — "SignalR for high-level"
- Intent: transport fallback; scaling.
Pattern 4 — "Heartbeats to keep alive"
- Intent: prevent intermediary disconnects.
Pattern 5 — "Stream IAsyncEnumerable to SSE"
- Intent: clean .NET API.
Pros & cons / trade-offs
| Tech | Pros | Cons |
|---|---|---|
| WebSocket | Bidirectional; binary | State; scaling |
| SSE | HTTP-simple; auto-reconnect | Server→client only; text |
| SignalR | Abstracts both | Some overhead |
| Long polling | Compat | Inefficient |
When to use / when to avoid
- Use SSE for log/progress/notifications.
- Use WebSocket for chat/games.
- Use SignalR if you don't need raw control.
- Avoid raw when SignalR fits.
Interview Q&A
Q1. WebSocket vs SSE? WebSocket: bidirectional, binary, persistent. SSE: server→client, text, HTTP-friendly.
Q2. Why SSE over WS for notifications? Auto-reconnect; HTTP-friendly; simpler. No need for bidirectional.
Q3. SSE auto-reconnect? Browser EventSource reconnects automatically. Sends Last-Event-ID so server can resume.
Q4. WS scaling? Sticky LB + backplane (Redis pub/sub, Service Bus).
Q5. Auth on WebSocket? Token in query string; JwtBearer's OnMessageReceived reads.
Q6. Heartbeats? Periodic empty messages; prevent intermediary timeouts.
Q7. CancellationToken in SSE handler? ctx.RequestAborted fires on disconnect; loop must check.
Q8. SSE format? data: ...\n\n per event. Optional event:, id:, retry:.
Q9. WS framing? .NET handles. WebSocketMessageType.Text or Binary. End of message flag.
Q10. SSE backpressure? You manage. Don't push faster than client can consume.
Q11. SignalR vs raw — for senior? Use raw only when you need protocol control; otherwise SignalR.
Q12. WebSocket compression? permessage-deflate; modern browsers negotiate.
Gotchas / common mistakes
- ⚠️ No CancellationToken honored — leaked tasks.
- ⚠️ Auth header on EventSource/WS — browsers can't.
- ⚠️ No heartbeats — connections die silently.
- ⚠️ Multi-instance without backplane — partial broadcasts.
- ⚠️ Building chat on SSE — no client→server.