Skip to main content

Threading

BlazorNative renders on one thread it owns. This page is the contract for that thread: what runs on it, what it means for the code a return code reports, how to get back onto it safely, and what happens if you don't.

The render thread​

Every mounted session gets one dedicated thread, named BlazorNative-Render. Blazor's own Dispatcher sits on it, and Dispatcher.CheckAccess() answers honestly — it is true only when you are actually on that thread.

The shell's own dispatch lane hands each UI event and host event to the render thread and waits, but only for the handler's synchronous part — up to its first await that suspends, if it has one. An await on a call the shell answers inside its begin does not suspend. A handler that yields frees the lane at once; the render thread keeps running the continuation on its own. When that continuation resumes, it resumes on the render thread too, same as it started — nothing needs to marshal back manually just because an await happened.

Source: RenderThreadDispatcher.cs and NativeRenderer.cs.

Return codes: the rc contract​

blazornative_dispatch_event's return code is written once in Exports.cs and once more, verbatim, in BlazorNativeRuntimeC.h, and once more on this page, so the three can't drift apart: RcContractCopiesTests fails when they differ.

rc reports the SYNCHRONOUS part of the handler: 0 = it did not fault, or it faulted only
after it began a host call or a fetch (it may still be running); 2 = it faulted before
beginning one. A fault after the handler has begun a host call or a fetch arrives as a
FaultNotice host-call op, never as an rc, whether the shell answers inside `hostCallBegin` or
later. Frames from the synchronous part are delivered before the export returns; frames from a
continuation are delivered later, from the render thread.
A fault belongs to the dispatch whose own code raised it, even when that code runs inline
inside another dispatch's synchronous part; if that dispatch has already finished, the fault is
only logged. Code that flows no dispatch, and a render or a post-dispatch action that any code
queues, belong to the dispatch whose window runs them.

In plain terms: rc only ever describes the synchronous half of a handler. Once that synchronous part, which includes the handler's own re-render, has begun a host call or a fetch, a fault after that never comes back as an rc, whether the shell answers at once or later: by then the export that would have reported it has returned 0, or is about to. It is delivered as a FaultNotice host call instead, which reaches the shell's onError on a thread-pool thread, not the render thread. A fault in the synchronous part before any such call is rc 2. A fault after the handler yields, on anything, also arrives as a FaultNotice. A refused begin does not count as a begun call. This is true in production mode as well as strict/debug mode: a late fault is never silently dropped just because the app isn't running under a debugger.

Re-rendering from another thread​

An off-thread call to StateHasChanged throws — exactly the rule Blazor Server enforces, for exactly the same reason: the render tree is not safe to touch from two threads at once. If your own code (a timer callback, a background thread, a native SDK callback that isn't already routed through the bridge) needs to update the UI, marshal back with InvokeAsync:

@implements IDisposable

@code {
private int _ticks;
private System.Threading.Timer? _timer;

protected override void OnInitialized()
=> _timer = new System.Threading.Timer(_ => OnTick(), null, 0, 1000);

public void Dispose() => _timer?.Dispose();

private void OnTick()
{
_ticks++;
// The timer callback runs on a thread-pool thread, not the render thread.
// InvokeAsync marshals the call onto the render thread before StateHasChanged runs.
_ = InvokeAsync(StateHasChanged);
}
}

A handler a native widget raises is already on the render thread, so calling StateHasChanged() directly there is fine — see State in BlazorNative for that half of the picture. InvokeAsync only matters once you start work of your own that doesn't begin on that thread.

One more trap worth naming: don't put ConfigureAwait(false) before a render inside a component. It is the standard, correct habit in library code, and it is wrong here — it can resume your continuation off the render thread, which is exactly the condition above. There is no compiled counterexample on this page for it (the resulting failure is intermittent, not a compile error), but there is an open issue for a compile-time guard: #427.

Don't block the render thread​

Never call .Result, .Wait() or .GetAwaiter().GetResult() inside a handler. The render thread is shared: the shell's dispatch lane waits for your handler's synchronous part, and every event or navigation queued behind it waits for that lane, one for one. A handler that blocks for 200 ms holds the lane for 200 ms — measured, not estimated: a slow handler's synchronous part and the delay felt by the very next event track each other almost exactly — see the measurement in docs/plans/2026-09-27-phase-16.3-record.md. There is no back-pressure and no timeout; the next event simply waits as long as you do.

// Anti-pattern — never do this inside a handler. It blocks the render thread until the
// work finishes, and the dispatch lane — and every event queued behind it — wait with it.
static Task SlowWork() => Task.Delay(200);

SlowWork().GetAwaiter().GetResult();

await the work instead. The analyzer BN0004 catches the most literal form of this, Thread.Sleep, which stalls the same way a blocking wait does; it does not (and cannot) catch .Result or .GetAwaiter().GetResult() in general, so treat this page as the rule and BN0004 as one thing it happens to be able to flag mechanically. See Analyzer rules for the full set.

The slow-handler warning​

Since a slow synchronous handler is diagnosable but not preventable by the framework, the renderer times every dispatch's synchronous part and warns when it runs long. The budget is 100 ms (NativeRenderer.SlowHandlerBudget) — chosen, not guessed; the phase record above has the reasoning.

A few things worth knowing about the warning before you see one:

  • It fires once per call site per session, then stays quiet for that call site. The key is the handler's own method — the declaring type and method name — so the same handler slow twice warns once, and a different slow handler warns on its own. A host-event arm (back, navigate, the lifecycle multicast) is keyed by event name instead, for the same reason.
  • It is capped at 32 distinct warnings per session. The next one after that logs one line — further slow-handler warnings suppressed — and then the warning goes silent until the session ends. Together with the once-per-call-site rule, this is what keeps a Release build quiet: BnLog's default level is Warn in every build (see Logging), so without these two limits a genuinely slow handler could spam every run.
  • It never includes the payload. The message names the handler, the event and the elapsed time — never the arguments the handler was called with, which can carry a user's own input.

Framework controls are looked through. When the slow handler is yours but a BlazorNative control invokes it, the warning names your handler, not the control. This holds for the events these controls forward: the change handler of BnInput, BnCheckbox, BnPicker, BnSlider and BnSwitch, and the dismiss click of BnModal.

A bound input names its page and a compiler-generated lambda, for example Page.<BuildRenderTree>b__0_0, because the Razor compiler turns @bind-Value into a lambda on the page. That covers plain @bind-Value too. With @bind-Value:get and :set, the warning names your :set method. Two bound inputs on one page each get their own warning. A friendlier name is tracked as #522.

Back on Android, briefly​

Pressing back never blocks Android's main thread. .NET decides whether there is somewhere to go back to and pushes that answer to the shell as it changes — the shell never asks .NET on the main thread and waits for a reply. At the root of the app, with nothing left to go back to, the press falls through to the platform's own default back behavior; on Android 12 and later that moves the task to the background rather than closing it. iOS has no system back gesture or button to route this way, so this section is Android-specific.

Shutdown​

blazornative_shutdown quiesces the render pipeline before it returns: no frame reaches the shell after that call comes back, even from a handler that never yielded. That guarantee covers frames only. A late fault that races shutdown — a handler resuming and throwing while shutdown runs — has two possible outcomes:

  • it is dropped, because its pending task was cancelled first; or
  • its fault notice is delivered after blazornative_shutdown has returned.

The bridge callbacks live for the whole process, so the second outcome is safe, but it means your shell's onError can fire after shutdown. Don't tear down what onError touches on the assumption that shutdown silenced it. Either way the fault still reaches stderr.