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#

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.

TopologyBackplane trafficFailure blast radiusWhere it wins
One connection-dense nodeNoneTotal — the box is the tier; clients resync on restartTens of thousands of connections; lowest latency, simplest ops
Few large nodes + Redis backplaneEvery message × a small NOne node drops its sockets; the rest keep servingThe sensible default: availability without drowning the backplane
Many small replicas + backplaneEvery message × a large NSmall per node, but every replica pays full fan-outRarely — the tax scales with node count, not with load
Azure SignalR ServiceOffloaded to the managed serviceConnection layer is the vendor’s problemBursty or very large fleets where connection ops isn’t your business
  • 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.