Blazor Async — Subscribe, not await
TL;DR Every mesh / hub call returns
IObservable<T>. YouSubscribe(...)to it — neverawait, never.ToTask(), never.FirstAsync().ToTask(), never.FirstOrDefaultAsync(). State updates from your Subscribe callback always go throughInvokeAsync(...)so they run on the Blazor circuit's dispatcher.
This is the companion article to Asynchronous Calls, which covers deadlock semantics in hub-handler code. This page is the practical playbook for Blazor components, layout-area views, and click-action handlers.
await blocks the Blazor dispatcher waiting for the hub — which needs the dispatcher to deliver the response — causing deadlock. Subscribe keeps the dispatcher free; the hub pushes values into the callback, which calls InvokeAsync(StateHasChanged) to re-render.
Why await is wrong in Blazor
There are two distinct failure modes — one is a deadlock, the other is a stale-data problem.
1. Deadlock — shared scheduling infrastructure
Awaiting a hub round-trip inside a Blazor component method parks the circuit's dispatcher on a response that must itself be delivered through scheduling the circuit participates in. Whether a given await actually closes the cycle depends on which schedulers the two sides land on, so this is a latent deadlock, not one that reproduces on every call — which is exactly what makes it dangerous: it passes in development and closes under concurrency (multiple users, parallel queries from one page, rapid keystrokes).
This is the same root cause as the hub-handler deadlock described in Asynchronous Calls, with one extra layer: the awaiting context is the Blazor circuit rather than a hub ActionBlock.
Do not treat "I couldn't make it hang locally" as evidence that an await is safe here — the observed production failure of this shape was not a clean hang but a storm: a blocked circuit turned a mishandled read into repeated re-requests that wedged a partition hub and took the portal with it. Failure mode §2 below is independently sufficient to forbid the pattern anyway.
2. Stale data — Task<T> is a snapshot, not a subscription
A Task<T> resolves once. If you bind your view to a Task<T> result, it shows the value captured at call time and never updates when the underlying mesh state changes. Even if the await worked, you'd see a stale snapshot the moment the data moves.
The correct primitive is IObservable<T> — it emits the initial value and every subsequent change. The view re-renders on each emission via InvokeAsync(StateHasChanged).
Canonical patterns
Lifecycle: read live data, hold a subscription, dispose on tear-down
public partial class MyView : ComponentBase, IDisposable
{
private IDisposable? _sub;
private MeshNode? _node;
protected override void OnParametersSet() // sync — never OnParametersSetAsync
{
_sub?.Dispose();
// Shared per-path handle (IMeshNodeStreamCache) — same stream every
// other reader and the writer use for this path.
_sub = Hub.GetMeshNodeStream(BoundPath)
.Subscribe(node =>
{
_node = node;
InvokeAsync(StateHasChanged);
});
}
public void Dispose() => _sub?.Dispose();
}
Why OnParametersSet (sync) and not OnParametersSetAsync? Async lifecycle hooks invite an await on the mesh observable inside the body, which triggers the deadlock from §1. The sync variant forces the Subscribe shape and removes the temptation entirely.
Click handler: synchronous body, fire-and-forget Subscribe
private void OnSaveClicked()
{
MeshService.UpdateNode(_node!).Subscribe(
updated => InvokeAsync(() =>
{
_saveResult = "Saved.";
StateHasChanged();
}),
ex => InvokeAsync(() =>
{
_saveResult = $"Save failed: {ex.Message}";
StateHasChanged();
}));
}
Key rules:
- The handler returns
void(or wraps inTask.CompletedTaskforEventCallbacksignatures that require it) — neverasync void, neverasync Task. - State mutation goes inside
InvokeAsync(...)so it runs on the Blazor dispatcher. - Both
onNextandonErrorpaths must update UI — never letonErrorfall on the floor.
Multiple parallel queries: CombineLatest instead of Task.WhenAll
When a view needs N independent results before rendering, replace Task.WhenAll(tasks) with CombineLatest:
private void LoadResults()
{
var observables = queries.Select(q => MeshQuery
.Query<MeshNode>(MeshQueryRequest.FromQuery(q))
.Take(1)
.Select(c => (IReadOnlyList<MeshNode>)c.Items)
.Catch<IReadOnlyList<MeshNode>, Exception>(
_ => Observable.Return<IReadOnlyList<MeshNode>>(new List<MeshNode>())));
observables.CombineLatest()
.Take(1)
.Subscribe(allBatches => InvokeAsync(() =>
{
_results = allBatches.SelectMany(batch => batch).ToList();
StateHasChanged();
}));
}
CombineLatest waits for the first emission from every observable before firing once with the assembled tuple. Add .Take(1) to complete the chain after that single combined result arrives.
Multi-step flow (e.g., export → ZIP → download)
When a click action does hub work followed by non-hub work (file I/O, JSInterop), there are two acceptable shapes.
Shape A — split at the boundary. The hub work is observable; the JS interop lives in a follow-on async Task invoked from the Subscribe callback:
private void ExportClicked()
{
ExportService.ExportToDirectory(SourcePath, tempDir, excluded)
.Take(1)
.Subscribe(
result => _ = InvokeAsync(() => OnExportCompletedAsync(result, tempDir)),
ex => _ = InvokeAsync(() => OnExportFailed(ex.Message, tempDir)));
}
private async Task OnExportCompletedAsync(MeshExportResult result, string tempDir)
{
// ZIP the directory, JSInterop download — no mesh awaits here.
using var ms = new MemoryStream();
ZipFile.CreateFromDirectory(tempDir, ms, CompressionLevel.Optimal, false);
using var streamRef = new DotNetStreamReference(ms);
await JSRuntime.InvokeVoidAsync("downloadFromStream", "export.zip", streamRef);
}
The await JSRuntime.InvokeVoidAsync(...) inside OnExportCompletedAsync is fine — JSInterop is not a hub round-trip.
Shape B — chain inside the observable. When follow-on work is synchronous or wrapped in Observable.FromAsync at a non-hub boundary:
ExportService.ExportToDirectory(...)
.SelectMany(result => result.Success
? Observable.FromAsync(ct => DoFileIo(result, ct)) // file I/O — sanctioned boundary
: Observable.Throw<Unit>(new InvalidOperationException(result.Error)))
.Subscribe(
_ => InvokeAsync(StateHasChanged),
ex => InvokeAsync(() => ShowError(ex.Message)));
Form submit (EditForm.OnSubmit)
private void Submit(EditContext context)
{
Stream.SubmitModel(model!).Subscribe(log =>
{
InvokeAsync(() =>
{
if (log.Status == ActivityStatus.Succeeded)
Reset();
else
ShowError(log);
StateHasChanged();
});
});
}
Submit is void, not async void. The framework's OnSubmit EventCallback accepts both, so the void form is always preferred.
Bridging to IAsyncEnumerable (autocomplete callbacks, etc.)
Some FluentUI / third-party APIs require Func<string, Task<T[]>> or IAsyncEnumerable<T>. Rather than await observable.FirstAsync().ToTask() (the deadlock pattern), use a Channel:
private async IAsyncEnumerable<MyItem> StreamItems(string query,
[EnumeratorCancellation] CancellationToken ct)
{
var channel = Channel.CreateUnbounded<MyItem>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
using var sub = MeshService.Query<MyItem>(query)
.SelectMany(c => c.Items)
.Subscribe(
item => channel.Writer.TryWrite(item),
_ => channel.Writer.TryComplete(),
() => channel.Writer.TryComplete());
await foreach (var item in channel.Reader.ReadAllAsync(ct))
yield return item;
}
The await foreach iterates a ChannelReader — that is not a hub round-trip Task bridge, so there is no deadlock surface. using var sub disposes the subscription when the consumer stops reading.
Forbidden patterns and their fixes
| ❌ Wrong | ✅ Right |
|---|---|
var x = await mesh.QueryAsync<T>("path:X").FirstOrDefaultAsync() |
mesh.GetMeshNode(path).Subscribe(n => UpdateState(n)) for a known path; mesh.Query<T>(req).Subscribe(...) for set queries |
var r = await Hub.Observe<R>(req).FirstAsync().ToTask() |
Hub.Observe(req).Subscribe(r => …, ex => …) |
var d = Hub.Post(req, o); Hub.Observe(d).Subscribe(…) |
Hub.Observe(req, o).Subscribe(r => …, ex => …) — pass the REQUEST. Observe(delivery) registers the response subject AFTER the post, and a reply landing in that window is dropped |
var x = await stream.FirstAsync().ToTask() |
stream.Take(1).Subscribe(x => UpdateState(x)) |
var x = await meshService.UpdateNode(node).FirstAsync() |
meshService.UpdateNode(node).Subscribe(n => …, ex => …) |
return Task.FromResult(_items.ToArray()) (callback returning a snapshot) |
Bind directly to _items; Subscribe pushes updates and StateHasChanged re-renders |
_ = LoadAsync(); await ... (fire-and-forget Task swallowing errors) |
Sync method that fires Subscribe(onNext, onError) — both paths handled |
private async void Submit(...) => await svc.DoAsync() |
private void Submit(...) => svc.Do().Subscribe(...) |
private async Task RunCell(int i) { ... await ExecuteAsync ... } |
private void RunCell(int i) { ... ExecuteCode(...).Subscribe(...) } |
await Task.WhenAll(observables.Select(o => o.ToTask())) |
Observable.Merge(observables) or Observable.CombineLatest(observables) + single Subscribe |
Lifecycle reference
| Hook | When to use it | Mesh I/O? |
|---|---|---|
OnInitialized (sync) |
One-time setup that doesn't read mesh state | Yes — Subscribe to streams here |
OnInitializedAsync |
Avoid for mesh reads. Sanctioned only for non-mesh async work (e.g. JSInterop ready check) | No mesh awaits |
OnParametersSet (sync) |
Re-subscribe when bound parameters change | Yes — dispose old IDisposable, subscribe to new |
OnParametersSetAsync |
Avoid for mesh reads. Same rationale as OnInitializedAsync |
No mesh awaits |
OnAfterRenderAsync |
JSInterop after first render (firstRender check) |
JSInterop only |
| Click / event handlers | Call services, post messages, update state | Subscribe, never await mesh observables |
Dispose |
Tear down subscriptions | Dispose all IDisposables held by the component |
When await IS allowed in Blazor code
There is a narrow set of awaits that are safe because they don't bridge a hub round-trip:
await JSRuntime.InvokeVoidAsync(...)/InvokeAsync<T>(...)— JSInterop is not a hub call.await InvokeAsync(...)to marshal a state mutation onto the Blazor dispatcher — this is the dispatcher itself, not a mesh round-trip.await Task.Delay(...)for debouncing — pure timer.await streamRef.WriteToStreamAsync(...)/ file I/O — sanctioned boundary.await foreach (var x in channelReader.ReadAllAsync(ct))— channel iteration, not a.ToTask()bridge.
Everything else — every await whose right-hand side touches IMessageHub, IMeshService, IWorkspace, ISynchronizationStream, or any extension of those — must become a Subscribe.
Cross-references
- Asynchronous Calls — the master doc on hub-handler deadlock semantics; this article specialises that to the Blazor surface.
- Blazor Data Binding — how layout-area paths bind to Razor view fields, and the canonical view-side stream patterns.
- CQRS — Queries vs. Content Access — when to use
QueryAsync/Queryvs.GetMeshNode/GetRemoteStream.