Real-Time Data Push to Clients
Clients poll every 3 seconds just to feel live
Use Case: Real-Time Data Push to Clients
The everyday problem: A dashboard must show live metrics, a chat app must deliver messages instantly, a notification bell must light up the moment something happens, a collaborative editor must reflect others' changes in real time, a stock ticker must stream prices. The traditional request/response model cannot do this — the server can only respond when asked, so the client has no way to learn about server-side changes except by repeatedly asking. The challenge is pushing data from server to client the instant it changes, rather than forcing clients to poll constantly and learn about updates late.
Five approaches along a spectrum from "simulate push with frequent requests" to "true bidirectional streaming". The right one depends on the direction of data flow, message frequency, browser/client constraints, and your scale.
Way 1: Short Polling (the baseline — and its limits)
Best used for: Simple cases with infrequent updates and loose latency requirements, or where infrastructure constraints rule out persistent connections — a status check every 30 seconds, low-stakes "has this finished yet?" polling.
The concept: The client repeatedly asks the server "anything new?" on a fixed interval. It is not real push at all — it simulates it by asking often enough that updates feel reasonably timely. Simple, universally compatible, but inherently wasteful and laggy.
// Client polls on an interval — most requests return "nothing new"
async function pollStatus(jobId) {
const interval = setInterval(async () => {
const status = await fetch(`/api/jobs/${jobId}/status`).then(r => r.json());
updateUI(status);
if (status.state === 'Completed' || status.state === 'Failed') {
clearInterval(interval); // stop once done
}
}, 3000); // every 3 seconds
}
// Server — an ordinary stateless endpoint, no special infrastructure
[HttpGet("jobs/{id}/status")]
public IActionResult GetStatus(string id)
=> _jobs.TryGet(id, out var status) ? Ok(status) : NotFound();
Why it works: It uses nothing but ordinary stateless HTTP requests — no persistent connections, no special server support, works through every proxy and firewall, and is trivial to implement and scale (the requests are just normal API calls). For genuinely infrequent updates it is perfectly adequate.
Benchmark note: Short polling's costs are latency and waste. Latency: an update is seen, on average, half the poll interval late (a 3-second poll means ~1.5s average delay). Waste: the vast majority of polls return "nothing new" — 1,000 clients polling every 3 seconds is ~333 requests/second of mostly-empty responses, each carrying full HTTP overhead. The faster you poll for lower latency, the more waste you generate — a fundamental tension. It does not scale to real-time needs (sub-second latency or many clients), but its simplicity and universal compatibility make it a legitimate choice when updates are rare. For anything needing low latency or many concurrent clients, move to push (Ways 3–5).
Way 2: Long Polling (hold the request until there is news)
Best used for: Near-real-time updates when true push (WebSockets/SSE) is unavailable or blocked by infrastructure — a fallback that achieves low latency over ordinary HTTP.
The concept: The client makes a request, and the server holds it open without responding until there is actually something to send (or a timeout is reached). When data arrives, the server responds immediately; the client processes it and instantly makes another long-poll request. This delivers updates promptly while still using standard HTTP request/response.
// Server holds the request until data is available or a timeout elapses
[HttpGet("notifications/wait")]
public async Task<IActionResult> WaitForNotifications(int userId, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(30)); // cap how long we hold the connection
try
{
// Wait until a notification arrives for this user (e.g. via a Channel or event)
var notification = await _notificationService.WaitForNextAsync(userId, timeout.Token);
return Ok(notification); // respond the instant there's news
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return NoContent(); // timeout with nothing new — client immediately re-polls
}
}
// Client: respond, then immediately poll again — a continuous loop
async function longPoll(userId) {
while (true) {
try {
const resp = await fetch(`/api/notifications/wait?userId=${userId}`);
if (resp.status === 200) handleNotification(await resp.json());
// 204 No Content → just loop again
} catch { await sleep(5000); } // back off on error
}
}
Why it works: By holding the request open, the server can respond the moment data is ready instead of waiting for the next poll — achieving low latency without true push. Empty responses are largely eliminated (the request only returns when there is news or a timeout), so it is far less wasteful than short polling while remaining ordinary HTTP that works through restrictive proxies.
Benchmark note: Long polling delivers near-real-time latency over standard HTTP, making it the classic fallback when WebSockets are unavailable. Its cost is held connections: each waiting client occupies a connection (and, if not handled with async/await properly, a thread) for up to the timeout duration — so many concurrent clients hold many open connections. Modern async ASP.NET Core handles held connections efficiently (no thread blocked while awaiting), which mitigates but does not eliminate the connection-count pressure. It is more complex than short polling and less efficient than true push. Today it is mainly a fallback that libraries like SignalR use automatically when better transports are blocked — rarely something you implement by hand anymore.
Way 3: Server-Sent Events (SSE — one-way server→client stream)
Best used for: One-directional streaming from server to client — live feeds, notifications, dashboard updates, progress streams, log tailing — where the client only needs to receive and does not need to send over the same channel.
The concept: SSE is a built-in browser standard (EventSource) where the client opens one long-lived HTTP connection and the server streams a continuous sequence of text events over it. It is true server push, one-way, built on plain HTTP, with automatic reconnection handled by the browser — simpler than WebSockets when you only need server→client flow.
// Server streams events over a single long-lived HTTP response
[HttpGet("stream/prices")]
public async Task StreamPrices(CancellationToken ct)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache";
await foreach (var price in _priceFeed.SubscribeAsync(ct))
{
// SSE wire format: "data: {payload}\n\n"
await Response.WriteAsync($"data: {JsonSerializer.Serialize(price)}\n\n", ct);
await Response.Body.FlushAsync(ct); // push it now, don't buffer
}
}
// Client: the browser's built-in EventSource — auto-reconnects on drop
const source = new EventSource('/api/stream/prices');
source.onmessage = (e) => updateTicker(JSON.parse(e.data));
source.onerror = () => console.log('reconnecting...'); // browser retries automatically
Why it works: SSE is purpose-built for one-way streaming: one HTTP connection, the server writes events as they occur, the browser delivers them to your handler and transparently reconnects if the connection drops (even resuming from the last event ID). For the very common case of "server needs to push updates to the client", it is simpler and lighter than the full bidirectional machinery of WebSockets.
Benchmark note: SSE is efficient for server-to-client streaming and pleasantly simple — built into browsers, runs over standard HTTP/HTTPS (so it passes through most proxies), and includes automatic reconnection for free. Its constraints define its scope: it is one-directional (client cannot send over the same connection — it makes separate requests for that), text-only (binary must be encoded, e.g. base64), and over HTTP/1.1 each SSE stream uses one of the browser's limited connections per domain (HTTP/2 multiplexing removes this limit). When the data flow is genuinely one-way, SSE is often the right, underused choice — many reach for WebSockets when SSE would be simpler. When you need the client to stream back too, use WebSockets/SignalR (Ways 4–5).
Way 4: WebSockets (full bidirectional, low-level)
Best used for: True two-way, low-latency communication — chat, multiplayer games, collaborative editing, live trading — where both client and server need to send messages freely over a persistent connection.
The concept: WebSockets upgrade an HTTP connection to a persistent, full-duplex TCP channel over which either side can send messages at any time with minimal per-message overhead. It is the lowest-level real-time primitive on the web — maximum flexibility and performance, but you handle connection management, message framing, reconnection, and scaling yourself.
// Raw WebSocket handling in ASP.NET Core
app.Use(async (context, next) =>
{
if (context.Request.Path == "/ws" && context.WebSockets.IsWebSocketRequest)
{
using var socket = await context.WebSockets.AcceptWebSocketAsync();
var buffer = new byte[4096];
// Bidirectional loop — receive from client, can send back any time
while (socket.State == WebSocketState.Open)
{
var result = await socket.ReceiveAsync(buffer, context.RequestAborted);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
var reply = Encoding.UTF8.GetBytes($"echo: {message}");
await socket.SendAsync(reply, WebSocketMessageType.Text, true, context.RequestAborted);
}
}
else await next();
});
Why it works: The persistent full-duplex connection means either side sends instantly with tiny overhead (a few bytes of framing per message, versus full HTTP headers per request) — ideal for high-frequency, low-latency, bidirectional exchange. It is the most powerful and performant real-time transport the browser offers.
Benchmark note: WebSockets offer the lowest latency and overhead per message of any option, and true bidirectionality — essential for chat, games, and collaboration. The price is that raw WebSockets are low-level: you implement connection lifecycle, reconnection with backoff, heartbeats/keep-alive, message serialization, and — critically — scaling across multiple servers, where a message must reach a client connected to a different instance (requiring a backplane). You also handle fallback for clients/networks that block WebSockets. This is a lot of undifferentiated plumbing, which is exactly why most .NET teams use SignalR (Way 5) instead of raw WebSockets — SignalR provides all of that on top of WebSockets. Use raw WebSockets only when you need precise low-level control or minimal dependencies.
Way 5: SignalR (the .NET real-time framework — recommended default)
Best used for: Virtually all real-time features in .NET applications — notifications, live dashboards, chat, collaborative apps — where you want WebSocket performance plus automatic fallback, reconnection, scaling, and a clean programming model without building it all yourself.
The concept: SignalR is ASP.NET Core's real-time framework. It uses WebSockets when available and automatically falls back to SSE or long polling when not. It handles connection management, automatic reconnection, grouping, and (with a backplane) scaling across servers — exposing a high-level hub API where server and client call each other's methods directly. It is the abstraction that makes real-time in .NET productive.
// Hub — server-side real-time endpoint
public class DashboardHub : Hub
{
// Clients can call this; server can call client methods on connections/groups
public async Task SubscribeToMetric(string metricName)
=> await Groups.AddToGroupAsync(Context.ConnectionId, metricName);
}
// Server pushes to clients from anywhere via IHubContext
public class MetricBroadcaster(IHubContext<DashboardHub> hub)
{
public async Task PublishMetricAsync(string metric, double value, CancellationToken ct)
// Push to only the clients subscribed to this metric
=> await hub.Clients.Group(metric).SendAsync("MetricUpdated", new { metric, value }, ct);
}
// Program.cs — including a Redis backplane for scaling across multiple servers
builder.Services.AddSignalR().AddStackExchangeRedis(redisConnString);
app.MapHub<DashboardHub>("/hubs/dashboard");
// Client — strongly-typed-ish, with automatic reconnection
const conn = new signalR.HubConnectionBuilder()
.withUrl("/hubs/dashboard")
.withAutomaticReconnect() // reconnection handled for you
.build();
conn.on("MetricUpdated", (d) => updateChart(d.metric, d.value)); // server→client call
await conn.start();
await conn.invoke("SubscribeToMetric", "cpu-usage"); // client→server call
Why it works: SignalR gives you WebSocket performance when available and graceful degradation when not, so it "just works" across diverse clients and networks. The hub model (server and client invoking each other's methods) is far more productive than hand-managing sockets and message framing. Groups make targeted broadcasting easy, automatic reconnection handles flaky networks, and the Redis backplane solves the multi-server scaling problem — a message published on server A reaches clients connected to server B.
Benchmark note: SignalR is the pragmatic default for real-time in .NET: you get near-WebSocket performance with all the operational hard parts (fallback, reconnection, scaling, grouping) handled. The scaling caveat to internalise: across multiple servers you must add a backplane (Redis) or messages only reach clients on the same instance that sent them — a classic bug when an app scales from one server to many. Each persistent connection consumes server memory, so very high connection counts (hundreds of thousands) require capacity planning and possibly Azure SignalR Service (a managed backplane + connection host that offloads connection management entirely). SignalR 9's stateful reconnect lets clients survive brief disconnects without losing messages. For the overwhelming majority of .NET real-time needs, SignalR is the right starting point over raw WebSockets.
Choosing the right approach
- Infrequent updates, simplicity over latency → Short polling (Way 1)
- Near-real-time but push is unavailable → Long polling (Way 2) — mostly a fallback
- One-way server→client streaming → Server-Sent Events (Way 3)
- Two-way, need low-level control / minimal deps → Raw WebSockets (Way 4)
- Almost any real-time feature in .NET → SignalR (Way 5) — recommended default
The decision spine: first ask which direction does data flow? If it is purely server→client (a feed, notifications, a dashboard), Server-Sent Events (Way 3) is simpler than WebSockets and often the overlooked right answer. If it is bidirectional (chat, games, collaboration), you need WebSockets — and in .NET that almost always means SignalR (Way 5), which wraps WebSockets with the fallback, reconnection, and scaling you would otherwise build yourself. Polling (Ways 1–2) is for when updates are rare (short polling) or true push is blocked by infrastructure (long polling as a fallback). The two rules that matter most at scale: persistent connections consume server resources, so plan capacity (and consider a managed service like Azure SignalR for huge connection counts), and any multi-server real-time setup needs a backplane (Redis) so a message can reach a client connected to a different instance — forgetting this is the single most common real-time scaling bug.
Note: this is an informational engineering overview. Latency, connection limits, and scaling behaviour depend on infrastructure and configuration. Always measure against your own environment and client mix.