Real-Time UI Patterns
We ran push and polling side by side in one production app for years, and skipped the option in the middle entirely. Not a theoretical comparison - the same users, the same network, the same why-is-this-slow messages.
Every real-time feature starts with the same question: how fresh does this data actually need to be? It sounds simple, but the answer shapes your architecture, your server costs, and how your users perceive the product. I learned it the hard way building dispatch and tracking software for a freight brokerage - a transportation management system where dispatchers stare at live load boards for eight hours a day, and a stale grid means a missed pickup.
Over a few years our team ran two different answers to that question in production at the same time, for different features, plus a third non-answer for the features that did not need one. Comparing them under the same users and the same network conditions taught me more than any of the write-ups I had read. Here is what we learned.
The three patterns, quickly
Polling: the client asks the server whether anything is new on a fixed interval, gets an immediate answer even when the answer is no, and asks again later.
setInterval(async () => {
const res = await fetch('/api/loads/updates');
const data = await res.json();
updateGrid(data);
}, 5000);Long-polling: the client asks the same question, but the server holds the request open until there is something to report or a timeout hits, then responds - and the client immediately re-asks.
async function longPoll() {
const res = await fetch('/api/notifications/poll', { signal: timeoutSignal(120_000) });
if (res.ok) {
const data = await res.json();
handleNotifications(data);
}
longPoll(); // immediately re-open the connection
}SignalR: a persistent, bidirectional connection where the server pushes the instant something happens. No asking required.
public class LoadHub : Hub
{
public async Task NotifyLoadUpdated(LoadDto load)
{
await Clients.Group(load.BrokerageId).SendAsync("LoadUpdated", load);
}
}Textbook advice usually says to use WebSockets or SignalR for anything real-time. In practice we did not, and the reasons why are more interesting than the patterns themselves.
Where we used each one, and why
SignalR for the live load grid
Dispatchers spend an entire shift watching a grid of active loads - status changes, driver check-ins, ETA updates. This is where staleness is genuinely dangerous: a dispatcher working off a status that is thirty seconds old can double-book a driver or miss a detention window.
The deciding factor: the update rate was unpredictable and could spike hard. In a normal hour a handful of loads change status. During a weather event or a terminal backup you get dozens of updates in seconds across hundreds of loads. Polling on a fixed interval means being either too slow during the spike or wastefully chatty during the quiet, and you have to pick which in advance. A push model scales its output to match reality instead of guessing at an interval.
The tradeoff we paid was connection management. SignalR groups, reconnection logic , and making sure a dispatcher who alt-tabs away for twenty minutes does not come back to a silently dead connection all took real engineering effort. We ended up writing our own reconnection-with-backoff wrapper and a resync-on-reconnect call that pulls a fresh snapshot, because trusting the hub to have delivered every message through a connection blip was a mistake we made exactly once.
Polling for notifications
Our notification system - load assigned to you, document uploaded, rate confirmation signed - ran on plain fixed-interval polling. Every client asked for the notification list every two minutes, and got an answer immediately whether or not anything had changed.
It was the pragmatic choice, and honestly it worked fine for years. Notifications are not life-or-death immediate, and nobody minded a delay of a minute or two on a document-uploaded alert. Polling is also the pattern nothing can object to: no persistent connection per tab, no upgrade request for a corporate proxy to refuse, nothing to reconnect after a laptop sleeps. It is the option with the fewest ways to fail.
The cost scales with users, not with events: that is the whole problem with a fixed interval. A thousand idle users cost exactly as much as a thousand busy ones, because the question gets asked on a timer rather than when something happens. Ours also re-fetched the full notification list rather than a delta, so the expense grew with each user backlog as well as with headcount - which eventually showed up as real server load, and as timeouts for the users carrying the largest backlogs.
The one we skipped
Long-polling is the obvious middle ground between those two, and we never used it. Not on principle - it just never won an argument. While polling was good enough, long-polling was more machinery for a problem we did not have yet. By the time polling stopped being good enough, we had already built and hardened a SignalR hub for the grid, so the middle option was competing against infrastructure that was already paid for.
It is still the right answer for plenty of teams. It is the best option available when you need better latency than a timer allows but a persistent connection is off the table - an older stack, a hostile proxy, a platform where WebSockets are a fight. We were not in that position, so we went from the simplest pattern straight to the most capable one and skipped the compromise in between.
Manual refresh for everything else
For reports, historical searches, and secondary grids that do not need to be live, we used a refresh button. The user asks, we make one request, done.
This is the pattern people forget to mention because it is the least glamorous, and it has the best cost-to-value ratio of the three for a large share of features. If a dispatcher is reading a settlement report from last week, there is no value in pushing updates to it. Spending engineering effort on real-time infrastructure for data that does not change in real time is just waste.
Why we eventually moved notifications to SignalR too
User expectations moved: as the product grew, a notification taking up to two minutes to appear started to feel broken rather than acceptable - especially as we added more time-sensitive types, like a driver flagging a problem mid-route. The interval had not changed. What counted as acceptable had.
And once we had already built and hardened the SignalR infrastructure for the load grid - the reconnection handling, the group management, the resync-on-reconnect pattern - the incremental cost of routing notifications through the same hub was small. We were not paying for a second real-time system. We were extending one we already trusted.
The other quiet win was operational. Consolidating onto one transport meant one thing to monitor, one connection lifecycle to reason about, and one set of network and proxy quirks to support instead of two. That simplicity ended up mattering as much as the latency improvement.
What I would tell someone choosing today
If I were advising a team picking a pattern for a new feature, I would boil it down to three questions:
- How bad is stale data, and for how long: seconds matter, push. Minutes are fine, a timer is fine, and it is far less to get wrong. Somewhere in between, and a persistent connection is not available to you - that is where long-polling earns its keep.
- How spiky and how frequent are the updates: high-frequency or bursty, push wins on both latency and server load. Low-frequency and predictable, the simplicity of polling may beat the operational overhead of a persistent connection.
- What is already in your infrastructure: if you already run a hub for one feature, the marginal cost of adding a second to it is usually lower than standing up a separate mechanism. That is exactly the argument that eventually moved our notifications over.
None of these is objectively better. They are tools with different cost profiles - server resources, implementation complexity, network compatibility - traded against different latency requirements. The mistake is picking one as a default and reaching for it everywhere. The dispatch grid, the notifications, and the report screen all had genuinely different requirements, and for most of their lifespan they used three genuinely different solutions, because that is what actually fit.