DEV Community

Preeti Islur
Preeti Islur

Posted on

Fixing Blazor Server Concurrency Issues: SignalR Circuits, StateHasChanged, and Thread Safety

Blazor Server doesn't run your app in the browser — it runs on the server and pushes UI updates to the browser over a persistent connection called a "circuit." This circuit is built into Blazor Server itself and exists for every app automatically, whether or not you ever write a line of SignalR code yourself. Every user session gets its own circuit, and every circuit runs on a single logical thread of execution managed by a synchronization context, similar to how WinForms or WPF UI threads work.

This is exactly where most concurrency bugs come from — and it's important to be clear that this is a circuit problem, not a "you added SignalR" problem. Any code that touches component state from a background thread, a timer, or an async callback outside the normal render flow can violate that single-threaded assumption, regardless of whether your app uses a custom SignalR Hub at all. Blazor won't always throw an obvious error either — sometimes it just silently corrupts UI state or throws a vague InvalidOperationException about the current thread.

It's worth separating two things clearly, since they're easy to conflate:

  • The circuit — Blazor Server's own internal connection that renders your components. Every Blazor Server app has this, automatically, with no code from you.
  • A custom SignalR Hub — something you explicitly add to your app for app-level real-time features (chat, notifications, live dashboards). This is optional and separate from the circuit.

The InvokeAsync/synchronization-context problem below applies to both — but it's fundamentally a circuit threading issue. A custom Hub callback is just one common trigger for it, not the cause.

The most common failure pattern

// BAD: updating component state from a background task
private void StartBackgroundWork()
{
    Task.Run(async () =>
    {
        await Task.Delay(2000);
        someField = "Updated!";
        StateHasChanged(); // this can throw or silently fail
    });
}
Enter fullscreen mode Exit fullscreen mode

The problem: Task.Run schedules work on a thread pool thread, completely outside the circuit's synchronization context. When that background thread calls StateHasChanged(), it's trying to trigger a UI update from a thread Blazor never expected to touch component state.

The fix: InvokeAsync

// GOOD: marshal back onto the circuit's synchronization context
private void StartBackgroundWork()
{
    Task.Run(async () =>
    {
        await Task.Delay(2000);
        await InvokeAsync(() =>
        {
            someField = "Updated!";
            StateHasChanged();
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

InvokeAsync (inherited from ComponentBase) dispatches the given delegate back onto the correct synchronization context for that circuit — this is the fix for almost every "why does my background update not show up" bug in Blazor Server.

When you also have a custom SignalR Hub

If your app additionally uses its own SignalR Hub (separate from the circuit) — say, to push updates to a component from a hub connection in a real-time dashboard or collaboration feature — the same underlying circuit rule applies. Hub callbacks run on their own thread, not the component's, so they hit the exact same synchronization-context problem as the Task.Run example above. The Hub isn't a special case; it's just another source of "code running outside the circuit's thread."

// BAD: hub callback directly touching component state
hubConnection.On<string>("ReceiveUpdate", (message) =>
{
    latestMessage = message;
    StateHasChanged(); // still the same problem
});
Enter fullscreen mode Exit fullscreen mode
// GOOD: wrap the callback body in InvokeAsync
hubConnection.On<string>("ReceiveUpdate", async (message) =>
{
    await InvokeAsync(() =>
    {
        latestMessage = message;
        StateHasChanged();
    });
});
Enter fullscreen mode Exit fullscreen mode

A subtler bug: concurrent renders on the same circuit

Even with InvokeAsync used correctly, if multiple async operations both try to update the same component in overlapping windows, you can get render exceptions like:

System.InvalidOperationException: The current thread is not associated with the Dispatcher...
Enter fullscreen mode Exit fullscreen mode

or

System.InvalidOperationException: A task was canceled.
Enter fullscreen mode Exit fullscreen mode

This usually means two different code paths are racing to call StateHasChanged() on the same component near-simultaneously. A practical fix is guarding state mutations with a lock object scoped to the circuit's own logic, not a global static lock (which would serialize unrelated users):

private readonly SemaphoreSlim _updateLock = new(1, 1);

private async Task SafeUpdate(Action updateAction)
{
    await _updateLock.WaitAsync();
    try
    {
        await InvokeAsync(() =>
        {
            updateAction();
            StateHasChanged();
        });
    }
    finally
    {
        _updateLock.Release();
    }
}
Enter fullscreen mode Exit fullscreen mode

Disposal and circuit lifecycle

A related class of bugs shows up when a component is disposed (user navigates away, tab closes) while a background task is still running and later tries to call InvokeAsync on a component that no longer exists.

// GOOD: guard against calls after disposal
private bool _disposed;

protected override void OnInitialized()
{
    _ = PollForUpdatesAsync();
}

private async Task PollForUpdatesAsync()
{
    while (!_disposed)
    {
        await Task.Delay(5000);
        if (_disposed) break;

        await InvokeAsync(() =>
        {
            // safe update here
            StateHasChanged();
        });
    }
}

public void Dispose()
{
    _disposed = true;
}
Enter fullscreen mode Exit fullscreen mode

Without this guard, you'll eventually see ObjectDisposedException or circuit errors in production logs from users who navigated away mid-operation — a very common source of intermittent, hard-to-reproduce bugs.

Quick checklist for diagnosing Blazor Server concurrency bugs

  • Any StateHasChanged() called from inside Task.Run, a timer callback, or a SignalR hub handler needs InvokeAsync.
  • Background loops need a disposal flag checked before every state update.
  • If multiple async paths update the same component, consider a lightweight lock scoped per-component, not global.
  • Watch server logs for InvalidOperationException mentioning the dispatcher or synchronization context — that's almost always this exact class of bug.

Takeaway

Most Blazor Server concurrency issues trace back to one root cause: code touching component state from outside the circuit's synchronization context. Once you internalize "anything not triggered directly by a Blazor lifecycle method or event handler needs InvokeAsync," most of these bugs become straightforward to both diagnose and prevent.

Top comments (0)