Live pages
How a Rask Server page reaches the browser, what it costs, and what you can say about the response.
Rask server-renders. The first GET returns a complete document — doctype, <head>, <body>, and every
event-handler id — and the client attaches to that markup rather than replacing it. There is no hydration
step: the ids in the served HTML are the event binding.
Every page is live
There is nothing to choose. builder.Services.AddRask() and app.MapRask<App>() are the whole of it, and
every page a Rask Server app renders is live:
- the
GETcreates the page's session and renders it; - the response carries the session id and the runtime script;
- the browser connects, and every state change from then on streams to it as a minimal diff.
A page with no handler still gets a session, so a component that pushes from a timer or an event subscription just works — there is no render-time guess about which pages need a connection, and so no page that guessed wrong and went silently inert.
What that costs is bounded, not unlimited:
| Bound | Default | What it does |
|---|---|---|
UnconnectedSessionGracePeriod |
10 s | A session whose browser never connects — a crawler, a bounced visit — is released after this. |
MaxSessions |
set it for your memory budget | A GET that would exceed it is answered 503 instead of taking a session the box cannot hold. |
So MaxSessions bounds live sessions and recent page loads together. Size it against both — see
Scaling.
When WebSockets are blocked
The browser connects with a WebSocket. Some networks refuse one — a corporate proxy, a captive portal, a few
mobile operators — and a page that cannot connect is not a page, so the runtime falls back on its own to
plain HTTP: a Server-Sent Events stream carries the frames down, and the tab's own frames go up as POSTs.
There is nothing to configure and nothing to turn off. It is the same protocol, the same session and the
same limits (MaxInboundFrameBytes, MaxInboundFramesPerSecond, MaxPendingHandlers, the grace period),
checked the same way — the host-only Origin check and the signed-in user, now on every request.
| Endpoint | What it carries |
|---|---|
GET /_rask/stream/{session} |
The server's frames, as an event stream, with a comment heartbeat every 15 s on a quiet page. |
POST /_rask/send/{session} |
The tab's frames, as a JSON array — whatever piled up while the previous POST was in flight. |
POST /_rask/leave/{session} |
Sent as the tab closes, so its session is freed now rather than after the grace period. |
When a tab switches. It tries the socket first, and makes HTTP the candidate when the socket errors
before opening, has not opened within 5 seconds, or is cut off within 5 seconds of opening three times in a
row. It remembers HTTP — for that tab only, in sessionStorage — once an HTTP stream has actually opened
where the socket did not. A server that is simply mid-redeploy fails both, and a tab is never left on the
slower transport for that; a new tab tries the socket again.
What it costs. One request per batch of interactions instead of a frame on an open socket, and one long-lived request per tab. Browsers allow only six HTTP/1.1 connections per origin, so a tab on the fallback holds one of them for as long as it is open: serve over HTTP/2, which Kestrel does over TLS and most proxies do by default.
Proxies and compression. A proxy that buffers the stream holds frames until it has "enough" of them,
which for a live page is for ever. Rask answers with Cache-Control: no-cache, no-transform and
X-Accel-Buffering: no (which nginx honours), and the heartbeat keeps a quiet stream inside the usual idle
timeouts — see Deployment. AddRaskSpaHost never compresses text/event-stream; if you add
response compression yourself, exclude that type too.
WebAssembly is not a render mode. A Rask app that runs in the browser is a single-page app of its own:
rask new --template wasmfor a standalone one, orrask new --template wasm-hostedfor a server that serves one from itsClient/folder. Either way the server renders none of its pages — see serving a WebAssembly app.
The initial GET waits for your data
OnMount is fire-and-forget by design: the render walk starts it, keeps walking, and the continuation
paints later over the live connection. That is right once a connection exists, and wrong for the first
response — where "later" is after the bytes have already gone.
So a page like this would serve its placeholder as the first paint, and as the entire document every crawler and cache ever saw:
public sealed partial class Weather(IForecastService service) : Component
{
private Forecast[]? _forecasts;
protected override async Task OnMount() =>
_forecasts = await service.GetForecasts();
protected override Component? Render() =>
_forecasts is null ? P["Loading…"] : Ul[_forecasts.Select(f => Li[f.Summary])];
}
The GET waits for that work, so the document carries the forecasts. Nothing in the component changes.
It renders in waves: render, wait for what that render started, render again. A wave is the right unit because resolved data mounts new components, which start their own work — a page whose list loads and whose rows then load is two waves, not one longer wait.
{ "Rask": { "Server": { "QuiescenceTimeout": "00:00:05" } } } // default; "00:00:00" disables the wait
It is also an environment variable (Rask__Server__QuiescenceTimeout=00:00:05), and
AddRask(configureServer: o => o.QuiescenceTimeout = 5.Seconds) still works, running after the
section.
Blowing the budget is not an error. The page is served as it stands, and the load finishes over the live
connection. It does mean a slow page holds a request open for up to that long, so size it together with
MaxSessions — the two multiply.
A page reached later, in place, is waited for the same way — a link, Back, Go() — under a budget of its
own, NavigationQuiescenceTimeout (150 ms): see
the page is sent when its first reads are back.
Work you deliberately detach is not waited on. A polling loop started with _ = Loop() returns
immediately from the hook, so the response goes out and the loop keeps pushing over the connection.
Work blocked on JavaScript is not waited on either, and cannot be. A JS call made during a render queues
onto a frame, and during the GET there is no client to send that frame to — so the awaiting task completes
once the browser has connected and never before. A hook that reads browser storage is exactly this shape:
protected override async Task OnMount()
{
var stored = await _protectedStorage.GetAsync<string>("token"); // needs the browser
// …
}
The render stops waiting the moment it sees a queued JS call, and the page finishes over the connection. Waiting would only have spent the whole budget on every page load.
Rask.Query
A query is waited for too. Rask.Query starts its fetch inside the client rather than returning it from a
lifecycle hook, so the render hands it over at the point a component reads it — which means the GET waits
for exactly the queries that page actually displays:
public sealed partial class Orders : Component
{
protected override Component? Render()
{
var orders = QueryClient.Query(new GetOrders());
return orders.IsLoading ? P["Loading…"] : Ul[orders.Data!.Select(o => Li[o.Ref])];
}
}
That page serves its orders, not its spinner.
Only a query with nothing to show holds the response. One that is disabled (QueryOptions.Enabled = false) is pending but has nothing coming, so waiting for it would spend the whole budget to change nothing.
One that is serving cached data while it revalidates has real content to render, and its refresh lands over
the live connection.
Worth knowing when reasoning about cache hits: the query cache is registered scoped (AddRaskQuery()), and
the static QueryClient reaches the current session's one — on the Server host, one cache per session. Every initial GET therefore starts cold — stale-while-revalidate only
arises after a navigation inside a live session, never on a first paint.
Caching
Every page carries Cache-Control: no-store, no-cache, must-revalidate, private and Pragma: no-cache.
The document holds a session id that belongs to one visit, so no cache — the browser's own back/forward cache
included — may keep it. Put public content that should be cached somewhere that renders without a session: a
prerendered WebAssembly site, or a plain ASP.NET endpoint.
Saying what the response is
Status codes
A path that falls through to the not-found page answers a real 404, which tells caches, crawlers and uptime checks that a missing page is missing.
The framework can only speak for the cases it knows about. /products/9999 matches a real route and renders a
perfectly ordinary "no such product" page — only the page knows:
public sealed partial class ProductPage(IPageResponse response, IProducts products) : Component
{
[RouteParam] public int Id { get; set; }
private Product? _product;
protected override async Task OnMount()
{
_product = await products.Find(Id);
if (_product is null)
{
response.SetStatus(404);
}
}
protected override Component? Render() =>
_product is null ? P["No such product."] : H1[_product.Name];
}
A faulted render still wins with 500 — a page that threw does not get to claim it succeeded. Setting 200 on
the not-found page is the supported way to express a deliberate soft-404.
IPageResponse is legal only during the initial server render (Render, OnMount, OnMount). From an
event handler it throws: by then the response is long gone, and a silently dropped status is worse than a
crash you can see. On WASM it is a no-op — there is no response to shape — so a component calling it runs
unchanged on both hosts.
Redirecting on load
Use Go.To, the same call you would make from a handler:
protected override async Task OnMount()
{
if (!_tenant.IsProvisioned)
{
Go.To("/onboarding");
}
}
During the initial render the host turns that into a real 302, before rendering a body at all — one
response instead of a whole page the client immediately navigates away from, and one a crawler and a cache both
understand where a client-side hop is neither. No session is left behind, and the redirect is no-store: one
computed from runtime state that a browser pinned would be unrecoverable without changing the URL.
Only same-site paths are accepted; anything else throws.
See also
- Lifecycle — when
OnMountruns and what the initial render waits for. - Routing —
[NotFound],Go, and redirecting from a lifecycle hook. - Scaling and Deployment — session accounting and sticky routing in production.
- Serving a WebAssembly app — a browser app, served by a server that renders none of its pages.