Request/response systems fail loudly: a slow endpoint times out, the client retries, dashboards light up. Push-based systems fail quietly. A consumer that reads a little slower than you produce does not error — it just falls behind, buffers grow somewhere, and twenty minutes later the process dies with an out-of-memory exception nowhere near the actual cause. If you are adding real-time features to a .NET system, the transport choice matters less than most comparisons suggest, and the backpressure design matters far more.

Choosing the transport#

OptionBest forWatch out for
PollingLow-frequency updates, simplest ops, HTTP caching worksLatency = poll interval; N clients × frequency becomes real load
Server-Sent Events (SSE)One-way feeds (tickers, notifications); plain HTTP, auto-reconnect built inOne-way only; browser connection limits on HTTP/1.1
Raw WebSocketsBidirectional, high-frequency, custom binary protocolsYou own reconnection, heartbeats, auth renewal, and fan-out yourself
SignalRBidirectional messaging in .NET shops; groups, reconnects, and fallbacks handledA framework, not a socket — its abstractions (and its backplane) come along

An honest default for .NET teams: polling until it hurts, SSE for one-way feeds, SignalR when the messaging is genuinely bidirectional or needs groups and presence, raw WebSockets only when a custom protocol or non-.NET clients make SignalR’s envelope unwelcome. The transports converge under the hood — SignalR uses WebSockets when it can. What you are really choosing is how much connection lifecycle code you want to own.

The part that actually bites: slow consumers#

Every push system has a producer that can occasionally go faster than some consumer. A market data feed bursts; a phone rides through a tunnel; a browser tab gets backgrounded and throttled. Whatever sits between producer and consumer — a Channel, a socket send buffer, a SignalR outbound queue — starts absorbing the difference.

  • Drop (conflate) when only the latest value matters. A price ticker or a live dashboard does not need every intermediate tick — a bounded channel that discards the oldest item is exactly right.
  • Block (throttle the producer) when every message matters and the producer can wait — internal pipelines, job queues. Backpressure propagates upstream, which is the point.
  • Disconnect when a client stays too far behind for too long. Cutting a consumer that cannot keep up protects every other consumer on the box; a good client reconnects and resyncs from a snapshot.

In code, this is what System.Threading.Channels is for. A bounded channel forces the decision at construction time instead of at 3 a.m.:

var channel = Channel.CreateBounded<Tick>(new BoundedChannelOptions(1024)
{
    SingleReader = true,
    // Conflate: a slow reader sees fewer, fresher ticks — never a dead server.
    FullMode = BoundedChannelFullMode.DropOldest,
});

SignalR exposes the same dial: streaming hub methods take a bounded channel or IAsyncEnumerable, and the per-connection outbound buffer is capped by ApplicationMaxBufferSize / TransportMaxBufferSize. Set those consciously. The unfashionable-but-correct pattern for high-frequency data is to stop pushing every event and instead push deltas on a timer — 10 updates per second, each containing only what changed — which turns worst-case fan-out from "producer rate × clients" into a constant you chose.

Scaling out: the backplane tax#

PRODUCERSREAL-TIME TIERCLIENTSpublishfan-outWebSocketWebSocketMarket data feedbursts to 5k msg/sHub node Abounded out-queuesRedis backplaneevery msg × N nodesHub node Bbounded out-queuesFast clientkeeps upSlow clientconflate or disconnect
A socket lives on one node, so every published message crosses the backplane to every node — total message volume, not client count, becomes the ceiling.

Real-time servers are stateful in one stubborn way: a WebSocket lives on one node. Two replicas behind a load balancer means a message published on node A must reach the client connected to node B. SignalR solves this with a backplane (Redis, or Azure SignalR Service as the managed offload) — every message is published to every node, which works well until total message volume, not client count, becomes the ceiling. This is the vertical-vs-horizontal trade-off again in miniature: a real-time tier often runs better as a few large, connection-dense nodes than as many small replicas, because each added node is another backplane subscriber paying the full fan-out.

  • Keep the real-time tier separate from the request/response API so their scaling curves (connections vs. CPU) stay independent.
  • Design the reconnect path first: a client must be able to reconnect, present what it last saw, and receive a snapshot plus deltas. If resync works, aggressive disconnection becomes a safe backpressure tool instead of a bug report.
  • Load-test with slow clients, not just many clients. A thousand fast consumers prove nothing about the one paused laptop that holds a 40 MB outbound buffer.
A real-time system is judged not by how fast it pushes when everything is healthy, but by what it does to the slowest consumer on its worst day.