From Page Refresh to fetch(): The Pre-Real-Time Era of Status Updates in ASP.NET Core
Part 1 of 2 — this post covers the client-driven techniques that came before real push-based updates. Part 2 covers Server-Sent Events and SignalR.
The Problem: "Is It Done Yet?"
Almost every non-trivial web application eventually needs to answer a simple question: something is processing on the server, and the user needs to know when it's finished (or how far along it is).
Generating a large report. Processing an uploaded video. Running a batch import. Training a model. Provisioning a resource. These operations may take longer than you want a single HTTP request to remain open, so the browser can't just wait around for a response — the user needs a way to check back in.
How you solve this problem has changed a lot over the last 20+ years, and ASP.NET Core developers today have more options than ever. This two-part series walks through the full evolution — from the humble page refresh to modern real-time push technologies — with context, trade-offs, and short C# code fragments for each.
Part 1 covers the "client keeps asking" era: page refresh, XMLHttpRequest, Ajax, fetch(), and polling. Part 2 covers the "server tells you" era: SSE and SignalR.
A Running Example
Throughout both articles we'll use the same scenario: a "Generate Report" endpoint that kicks off a background job and takes 20–30 seconds to complete. In ASP.NET Core, this is typically modeled with a background task queue and a status store:
app.MapPost("/reports", (IReportQueue queue) =>
{
var jobId = queue.Enqueue();
return Results.Accepted
($"/reports/{jobId}/status", new { jobId });
});
app.MapGet("/reports/{jobId}/status",
(string jobId, IReportQueue queue) =>
Results.Ok(queue.GetStatus(jobId)));
// { status: "Running", progress: 40 }
Everything below is about how the client finds out when that status changes.
Approach 1: Full Page Refresh
The oldest trick in the book: reload the entire page, either manually or on a timer, and let the server render the current status.
<meta http-equiv="refresh" content="5">
<p>Report status: @Model.Status
(@Model.Progress%)</p>
Every 5 seconds, the browser throws away the whole page and re-requests it from scratch. In classic ASP.NET, this was often paired with a "Refresh" button and a full round-trip through the page lifecycle.
The appeal of this approach is its sheer simplicity. It requires no JavaScript whatsoever, works in literally any browser that has ever existed, and leaves no client-side state to manage — the server is the single source of truth and the page just re-renders it. That simplicity comes at a real cost, though. The experience is jarring: the whole page flickers and reloads, scroll position resets, and any half-filled form state is lost. It's also wasteful, since the browser re-downloads the entire page — HTML, layout, stylesheets, scripts — just to reflect a one-line status change, when only a tiny fragment of the page actually needed to change. And because the refresh is all-or-nothing, there's no way to update just a progress bar or a status badge without redrawing everything around it, which makes this approach a poor fit for anything beyond the most trivial status checks.
Even so, it's still a reasonable choice today for low-traffic internal tools, admin dashboards where no JavaScript budget exists, or genuinely simple "check back later" pages where polish doesn't matter and reliability does.
Approach 2: XMLHttpRequest (the Ajax Foundation)
XMLHttpRequest (XHR), introduced in the late 1990s and popularized in the early 2000s, was the real turning point — it let JavaScript fetch data from the server without reloading the page. This is the technology "Ajax" is built on.
const xhr = new XMLHttpRequest();
xhr.open("GET", `/reports/${jobId}/status`);
xhr.onload = () => {
const data = JSON.parse(xhr.responseText);
document.getElementById("status")
.textContent = data.status;
};
xhr.send();
The ASP.NET Core side is the same status endpoint shown earlier — nothing changes on the server; XHR just changes how the client consumes it.
The significance of XHR is hard to overstate: it meant a page could quietly ask the server for fresh data and update just one piece of the DOM in response, which is precisely what full-page refresh couldn't do. That single capability is what made every subsequent JavaScript-driven web application possible. The API itself, though, has aged badly. It's verbose and callback-based, which makes even simple request/response logic harder to read than it should be, and it leaves error handling and response parsing entirely up to the developer. Next to the async/await code most developers write today, working with raw XHR feels distinctly clunky — which is exactly why layers were quickly built on top of it.
Approach 3: Ajax (jQuery Era) and fetch()
"Ajax" (Asynchronous JavaScript and XML) is a pattern, not a specific API — it refers to using XHR asynchronously to update parts of a page. jQuery's $.ajax() became the dominant way to do this for years because it smoothed over XHR's rough edges and cross-browser quirks:
$.getJSON(`/reports/${jobId}/status`, data => {
$("#status").text(data.status);
});
fetch(), introduced natively in browsers around 2015, is the modern replacement — promise-based and far cleaner:
async function checkStatus(jobId) {
const res = await fetch(`/reports/${jobId}/status`);
const data = await res.json();
document.getElementById("status").textContent = data.status;
}
To actually track an ongoing job, you wrap this in a timer — this is short polling:
const interval = setInterval(async () => {
const res = await fetch(`/reports/${jobId}/status`);
const { status, progress } = await res.json();
updateUI(status, progress);
if (status === "Completed") clearInterval(interval);
}, 3000);
Compared to raw XHR, fetch() is a genuine pleasure to work with — the promise-based, async/await-friendly syntax is clean, native to the browser with no library required, and easy for any developer to read and debug at a glance. But wrapping it in setInterval doesn't change what's actually happening underneath: this is still polling, which means the client is essentially guessing when it's worth asking again. Most of those guesses are wrong in the sense that nothing has changed yet, so a good portion of the requests are wasted. There's also an inherent latency — the user only learns about a status change up to one polling interval after it actually happened — and if you have many clients polling frequently, the server ends up fielding a steady stream of near-identical "any news?" requests, which doesn't scale gracefully as usage grows.
Interlude: Short Polling vs. Long Polling
The setInterval + fetch() pattern above is short polling — ask repeatedly, get an immediate (often "nothing new yet") answer each time.
Long polling flips this: the client makes a request, and the server holds it open — not responding until there's actually new data (or a timeout is reached). The moment the client gets a response, it immediately re-issues the request.
A simplified ASP.NET Core minimal API version, using a CancellationToken and polling an in-memory status store until either the job progresses or a timeout hits:
app.MapGet("/reports/{jobId}/status/long-poll",
async (string jobId, IReportQueue queue,
CancellationToken ct) =>
{
var timeout = DateTime.UtcNow.AddSeconds(25);
while (DateTime.UtcNow < timeout &&
!ct.IsCancellationRequested)
{
var status = queue.GetStatus(jobId);
if (status.HasChanged)
return Results.Ok(status);
await Task.Delay(500, ct);
// check periodically without blocking a thread
}
return Results.Ok(queue.GetStatus(jobId));
// return current state on timeout
});
Long polling meaningfully improves on short polling in the ways that matter most: latency drops dramatically, since the client hears about a change almost the moment it happens rather than waiting for the next tick of a timer, and the number of wasted round trips falls sharply compared to polling every few seconds regardless of whether anything has changed. The trade-off is that each held-open request ties up a connection — and, without careful async design on the server, real resources — for as long as it's kept waiting. It's also still not true server push; it's a clever illusion built entirely on top of ordinary request/response, dressed up to feel instantaneous. And because every resolved long-poll immediately triggers a new one, you get a constant churn of connections opening and closing, which adds its own complexity around timeouts and cancellation that needs to be handled carefully to avoid resource leaks at scale.
Where This Leaves Us
Every technique so far shares the same fundamental limitation: the client has to keep asking. Even long polling is just a more patient version of "are we there yet?" What these approaches are all reaching for — without quite getting there — is a way for the server to simply tell the client when something changes.
That's exactly what Server-Sent Events and SignalR provide, and that's where Part 2 picks up.
Next: Part 2 — Real-Time at Last: SSE and SignalR in ASP.NET Core
That’s all for now. May your intention be clear and your mind be still. With this quiet wish, I rest my pen and return to the silence.