Skip to content

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);
    }
});
const es = new EventSource("/events");
es.onmessage = e => console.log(JSON.parse(e.data));

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: hello\n\n

event: tick
id: 42
retry: 3000
data: {"x": 1}\n\n
  • data: — payload (multi-line OK).
  • event: — type for addEventListener('tick', ...).
  • id: — last event ID; client sends in Last-Event-ID header 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:

  1. Sticky sessions at LB — same client → same server.
  2. 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:

await ctx.Response.WriteAsync(": keep-alive\n\n", ct);

Authentication

WebSocket

[Authorize]
app.Map("/ws", async ctx => { /* ... */ });

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

while (!ct.IsCancellationRequested) { /* ... */ }

Always honor ctx.RequestAborted — fires when client disconnects. Stops your loop.

Buffer sizes

new WebSocketAcceptContext { ReceiveBufferSize = 16 * 1024 }

Default is 4KB. Larger for binary-heavy.

Compression

WebSocket has permessage-deflate. Enable in ASP.NET:

app.UseWebSockets(new WebSocketOptions { /* ... */ });

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

while (true) { /* loop forever even after client disconnects */ }

✅ Correct: honor

while (!ctx.RequestAborted.IsCancellationRequested) { /* ... */ }

❌ Wrong: long polling reinvented

// Polling /events every second; throws away SSE benefits

✅ 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.

Further reading