Realtime in Blazor
Realtime lets a run report back to the people watching it. Inngest.Blazor shows
those messages in a Blazor page: the RealtimeView component subscribes to a topic, keeps what arrives and renders
your markup again each time something does. The same component runs in Blazor Server, in WebAssembly and in
interactive Auto, where a page starts on the server and moves to WebAssembly.
Where the messages come from depends on where the component runs, and the component does not know:
- On the server it reads Inngest itself, with the app’s signing key, through
IInngestRealtime. - In WebAssembly, which holds no key and should not, it reads the server-sent events of a
MapInngestRealtimeendpoint of the server, and the endpoint decides what each user may see.
The component asks for an IRealtimeSource, and each side registers its own.
Install
Section titled “Install”Install Inngest.Blazor in the project that holds your components. In an app that renders on the server and in
WebAssembly that is the client project, which the server project references. The package brings in Inngest, and it
references Microsoft.AspNetCore.Components.Web as a package rather than the ASP.NET Core shared framework, so it
runs in the browser:
dotnet add package Inngest.BlazorThe types of the SDK are in the Inngest namespace. Add @using Inngest to the components that use the view, as the
samples do, and not to _Imports.razor, which would bring every name of the SDK, such as Priority and Throttle,
into every file.
Show a topic
Section titled “Show a topic”A function publishes to a typed topic. The view takes the
same topic, and the type of its data gives live.Latest.Data the same type:
public sealed record OrderStatus(string Text, int Percent);using System.Security.Claims;
public interface IOrders{ Task<bool> IsOwnedByAsync(string orderId, ClaimsPrincipal user);}@page "/orders/{OrderId}"@rendermode InteractiveServer@attribute [Authorize]@using Inngest@using Microsoft.AspNetCore.Authorization@using Microsoft.AspNetCore.Components.Authorization@inject IOrders Orders
@if (allowed){ <RealtimeView Topic="@status" Context="live"> @if (live.Latest?.Data is { } order) { <progress value="@order.Percent" max="100"></progress> <p>@order.Text</p> } else { <p>Waiting for the first update.</p> } </RealtimeView>}else{ <p>This order is not yours.</p>}
@code { [CascadingParameter] private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
[Parameter] public string OrderId { get; set; } = "";
private bool allowed;
private RealtimeTopic<OrderStatus> status => new($"order:{OrderId}", "status");
protected override async Task OnParametersSetAsync() => allowed = await Orders.IsOwnedByAsync(OrderId, (await AuthenticationState).User);}OrderId comes from the route, so the page checks that the signed-in user owns the order before it renders the view:
on the server the view reads any channel, as Blazor Server explains. The samples below leave that
check out and show only the view, which belongs in a page that makes it.
live is the RealtimeState<OrderStatus> of the view, and the content is rendered again whenever it changes:
| Member | Holds |
|---|---|
Connection |
A RealtimeConnectionState: Idle, Connecting, Open, Reconnecting, Closed or Failed. |
Error |
The exception that failed the subscription, while Connection is Failed. |
Messages |
The newest messages, the oldest first. At most MaxMessages of them. |
Latest |
The newest message of any topic. |
LatestByTopic |
The newest message of each topic, by topic name. |
TextByTopic |
The text of the newest stream of each topic, by topic name. |
A RealtimeMessage<T> has the Kind, Channel, Topic, Data, RunId and the other members described in
Realtime.
The component has these parameters:
| Parameter | Meaning |
|---|---|
Topic |
The RealtimeTopic<TData> to watch. Leave it null until you know what to watch. |
Channel, Topics |
Several topics of one channel, instead of Topic. TData is then JsonElement. |
MaxMessages |
How many of the newest messages Messages keeps. 100 by default. |
OnMessage |
An EventCallback<RealtimeMessage<TData>> called for each message, in order, after live has it. The next message waits until it returns. |
ThrowOnError |
Hands a failed subscription to the nearest ErrorBoundary. See Errors. |
ChildContent |
The content, given the RealtimeState<TData>. |
The view draws nothing of its own, so it brings no markup or style to override. With no ChildContent it still
calls OnMessage.
Several topics
Section titled “Several topics”A view holds one subscription, and a channel can carry several topics. Give the channel and the topics, and read the data as JSON, because the topics may carry different types:
@using System.Text.Json@using Inngest
<RealtimeView TData="JsonElement" Channel="@($"order:{OrderId}")" Topics="@(new[] { "status", "audit" })" Context="live"> @foreach (var (topic, message) in live.LatestByTopic) { <p>@topic: @message.Data</p> }</RealtimeView>
@code { [Parameter] public string OrderId { get; set; } = "";}The topics are compared by value, so a new array with the same topics on every render of the parent does not subscribe again. A different channel or other topics do: the old subscription is cancelled, the messages are cleared, and the view subscribes to the new ones.
Streams of text
Section titled “Streams of text”A function can stream text, such as a model’s reply.
It arrives as a StreamStart, StreamChunk messages with the text, and a StreamEnd. TextByTopic adds the
chunks up for you, and it does not forget the early ones when Messages has rolled over:
@using Inngest
<RealtimeView Topic="@reply" Context="live"> <p>@live.TextByTopic.GetValueOrDefault("reply")</p></RealtimeView>
@code { [Parameter] public string OrderId { get; set; } = "";
private RealtimeTopic<string> reply => new($"order:{OrderId}", "reply");}The text of a topic starts over with the next stream. It stops growing at 1,048,576 characters, which is above what Inngest allows a stream.
Blazor Server
Section titled “Blazor Server”Register the source next to AddInngest. It reads Inngest with the app’s signing key, so nothing of it reaches the
browser:
builder.Services.AddInngest(options => options.AppId = "shop");builder.Services.AddInngestRealtimeServerSource();That is all for Blazor Server and for the interactive server render mode. The view lives in the circuit: it updates itself through the renderer’s dispatcher, and it unsubscribes when the component is disposed, which a circuit that ends does for all of its components. Disposing waits up to five seconds for the subscription to stop. A circuit whose client has disconnected is kept by Blazor for a few minutes, and keeps its subscriptions meanwhile.
The source reads any channel with the app’s signing key, whoever the user is. A page that takes the channel from a
route value or a parameter, as the samples do, lets every user who can open it watch someone else’s order unless
it checks. Check in the page, before the view renders, as the first sample does with the AuthenticationState, or in
a service the page asks. The endpoint of the WebAssembly side has its own callback for this.
Each view on the server holds its own token and its own connection to Inngest, for as long as the circuit shows it.
The MapInngestRealtime relay and the SignalR bridge of Inngest.SignalR share one subscription for the same channel
and topics, however many pages watch it. So a page with many views, or many users on one channel, costs a connection
each here. To watch several topics of a channel use one view with a Channel and its Topics, as
below, and not a view for each.
WebAssembly
Section titled “WebAssembly”A WebAssembly app has no signing key, so the server relays the messages. MapInngestRealtime comes from
Inngest.AspNetCore, which the server project installs. Map an endpoint whose
callback checks that the signed-in user may see the channel, and returns the channel and the topics the request may
receive, or null for a 403:
app.MapInngestRealtime("/api/realtime/{channel}", context =>{ var channel = (string)context.Request.RouteValues["channel"]!;
// Check here that the signed-in user may see the channel. return ValueTask.FromResult<RealtimeSubscription?>(new(channel, ["status", "reply"]));}).RequireAuthorization();In the WebAssembly project, register the relay source and tell it where the endpoint for a channel and its topics is. An address without an origin is relative to the page:
services.AddInngestRealtimeRelaySource(options =>{ options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative);});The browser sends the channel in the address, so the callback of the endpoint is the only thing that decides what a user sees. The topics a view asks for are only a request: the endpoint serves what its callback returns, and the source drops messages of other topics.
The source reads the endpoint with an HttpClient and streams the response, as an EventSource would read it:
- Cookies go with a request to the page’s own origin. For an endpoint on another origin,
AddInngestRealtimeRelaySourcereturns the builder of theHttpClient, so you can set its base address and add message handlers, such as one that attaches an access token or asks the browser to send credentials. The endpoint then needs CORS. - A connection that ends or fails is opened again after a delay that grows from half a second to 30 seconds, and one that stayed open for five seconds or more is opened again at once. The relay ends a client that falls 512 messages behind, and connections drop. What is published meanwhile is missed, as Inngest keeps no messages. A connection silent for 45 seconds, three of the relay’s 15 second comments, is taken for dead.
- A refusal is not retried: 401, 403, 404 and the other 4xx answers fail the subscription, and so does an answer that is
not an event stream, such as the page of the app that a wrong address falls back to, or the login page that an
endpoint requiring authorization redirects to when the user is not signed in. The 429, 408, 425 and 5xx answers of a
relay or a proxy are retried. A 204 ends the subscription quietly, as it stops an
EventSource. - A browser’s
fetchholds a whole response back unless it is asked to stream it. The source sets that option on every request, as .NET 8 needs and .NET 10 does by default.
Typed topics are read with RealtimeRelayOptions.SerializerOptions. Blazor WebAssembly keeps reflection-based
serialization on and trims only partly, so they are read without anything more. An app that trims fully (TrimMode
full), or runs without reflection-based serialization, needs a source-generated context for the data types. Without
one the view fails, with a NotSupportedException about the type when the first message arrives, or when it
subscribes if reflection is off. Give the options the context:
using System.Text.Json.Serialization;
[JsonSerializable(typeof(OrderStatus))][JsonSerializable(typeof(string))]internal sealed partial class AppJsonContext : JsonSerializerContext;services.AddInngestRealtimeRelaySource(options =>{ options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative); options.SerializerOptions.TypeInfoResolver = AppJsonContext.Default;});Interactive Auto
Section titled “Interactive Auto”An Auto page renders on the server first and in WebAssembly once the app has downloaded. Put the page in the client project, so that both sides can render it, and register one source in each project. The server project also maps the endpoint that the WebAssembly side reads:
// Program.cs of the server projectbuilder.Services.AddInngest(options => options.AppId = "shop");builder.Services.AddInngestRealtimeServerSource();// Program.cs of the client projectservices.AddInngestRealtimeRelaySource(options =>{ options.Url = (channel, topics) => new Uri($"/api/realtime/{Uri.EscapeDataString(channel)}", UriKind.Relative);});The page that holds the view needs the render mode, because a view has a RenderFragment parameter, which cannot be
passed to an interactive component from a static page:
@page "/orders/{OrderId}"@rendermode InteractiveAuto@attribute [Authorize]@using Inngest@using Microsoft.AspNetCore.Authorization@using Microsoft.AspNetCore.Components.Authorization@inject IOrders Orders
@if (allowed){ <RealtimeView Topic="@status" Context="live"> <p>@live.Latest?.Data?.Text</p> </RealtimeView>}
@code { [CascadingParameter] private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
[Parameter] public string OrderId { get; set; } = "";
private bool allowed;
private RealtimeTopic<OrderStatus> status => new($"order:{OrderId}", "status");
// The server source reads any channel, so the page checks. In WebAssembly the endpoint's callback checks too, and // IOrders is then an implementation that asks the server. protected override async Task OnParametersSetAsync() => allowed = await Orders.IsOwnedByAsync(OrderId, (await AuthenticationState).User);}When the page moves from the server to WebAssembly the view starts again on the other side: it holds nothing from the circuit, and what was published between the two is missed.
Prerendering
Section titled “Prerendering”A view subscribes after its first interactive render and never before, so a page that is prerendered or rendered
statically does not subscribe, and neither does the prerendered copy of an interactive one. The view’s Connection
reads Idle in the prerendered HTML. The view does not depend on RendererInfo, which .NET 8 lacks.
A view asks for its IRealtimeSource when it subscribes, not when it is created. A server that only prerenders a page
for WebAssembly therefore needs no source of its own, and a missing registration shows as a failed view with the
missing service in Error, not as a page that does not render.
Errors
Section titled “Errors”A subscription that is refused, such as one with a wrong signing key or a 403 from the relay, does not end the
circuit or the page. The view logs a warning with the exception, Connection becomes Failed, and Error holds the
exception: an InngestException on the server, and an HttpRequestException with a StatusCode for the relay. Its
message is for the log, as it repeats what Inngest or the relay answered, so show the user a plain one and let them try
again with Restart:
@using Inngest
<RealtimeView @ref="view" Topic="@status" Context="live"> @if (live.Connection == RealtimeConnectionState.Failed) { <p>Live updates stopped.</p> <button @onclick="() => view?.Restart()">Try again</button> } else if (live.Connection == RealtimeConnectionState.Reconnecting) { <p>Reconnecting.</p> }</RealtimeView>
@code { private RealtimeView<OrderStatus>? view;
[Parameter] public string OrderId { get; set; } = "";
private RealtimeTopic<OrderStatus> status => new($"order:{OrderId}", "status");}With ThrowOnError, the failure goes to the nearest ErrorBoundary instead, which shows its error content; without
one it ends the circuit, as any unhandled exception in a component does. An exception from OnMessage fails the
subscription the same way.
Connecting reads while the subscription opens, and Open as soon as the connection is open, whether or not anything
is published. Both sources say when a connection opens, is lost and opens again, so a view shows Reconnecting on the
server and in WebAssembly. A source that cannot say, such as one over an implementation of IInngestRealtime that is
not the SDK’s own, leaves Open to the first message.
Memory and threads
Section titled “Memory and threads”A view keeps at most MaxMessages messages, one latest message for each topic, and the text of one stream for each
topic. Everything the content reads is changed on the renderer’s thread only, so it needs no lock: read it while
rendering or in an event handler, and copy what you keep. Messages reach the renderer one at a time, and a slow
renderer slows the reading down instead of piling messages up.
Outside components
Section titled “Outside components”IRealtimeSource gives the same messages as an IAsyncEnumerable<RealtimeMessage<T>>, to code that is not a
component, such as a service shared by the server and the client project of an Auto app:
var status = new RealtimeTopic<OrderStatus>("order:o-1001", "status");
await foreach (var message in source.SubscribeAsync(status, cancellationToken)){ Console.WriteLine($"{message.Data?.Text} ({message.Data?.Percent}%)");}It has the two SubscribeAsync overloads of IInngestRealtime, for a typed topic and for several topics of a channel
with the data as JsonElement, and reconnects the same way. Two more overloads take an onConnectionChanged
callback before the token, which a source tells Connecting, Open and Reconnecting; an implementation of the
interface writes those two and gets the others. In a test, register your own IRealtimeSource in the service
collection of the test context and render the component with bUnit.
What it does not do
Section titled “What it does not do”- It draws no markup and ships no JavaScript or CSS.
- It does not replay anything. A message published before the view subscribed, while it reconnected, or while a page moved from the server to WebAssembly is missed, because Inngest delivers only to subscribers that are connected.
- It reads the data of a topic as one type, and several topics as JSON. A channel and topics cannot be typed.
- It does not pause when the browser tab is hidden, and it does not interpret the run messages of the
$runtopic or close when a run ends, as the TypeScript SDK’suseRealtimehook can. - It does not carry messages from the prerendered page into the interactive one.
- It does not mint subscription tokens for the browser: the WebAssembly side uses the relay, and the TypeScript client is the way to connect a browser to Inngest directly.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.