SignalR with Blazor: Real-Time UI Updates

Blazor and SignalR are natural partners. Blazor Server already uses SignalR under the hood for its circuit, but you can also add your own SignalR connections for domain-specific real-time features. Blazor WebAssembly uses SignalR as a regular client. Let's look at both.

Blazor Server: You're Already Using SignalR

Blazor Server maintains a persistent SignalR connection (the "circuit") between the browser and server. UI events travel up, render diffs travel down. But this circuit is for framework use — you shouldn't use it for your own real-time messaging.

Instead, create a separate hub and connect to it from your components. This keeps your real-time domain logic separate from Blazor's rendering pipeline.

Setting Up the Hub

Define a hub as you normally would:

Example.cs
public interface INotificationClient
{
    Task ReceiveNotification(Notification notification);
    Task OrderUpdated(string orderId, string status);
}

public class NotificationHub : Hub<INotificationClient>
{
    public async Task SubscribeToOrder(string orderId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
    }
}

Register it in Program.cs:

Program.cs
builder.Services.AddSignalR();
app.MapHub<NotificationHub>("/hubs/notifications");

Blazor Component with SignalR

Create a component that connects to the hub. The key consideration is lifecycle management — start the connection when the component loads, dispose it when the component is removed:

Example.cs
@page "/orders/{OrderId}"
@implements IAsyncDisposable
@inject NavigationManager Navigation

<h3>Order @OrderId</h3>

<div class="order-status">
    <span>Status: @currentStatus</span>
</div>

@if (notifications.Any())
{
    <div class="notifications">
        @foreach (var notification in notifications)
        {
            <div class="notification">
                <strong>@notification.Title</strong>
                <p>@notification.Message</p>
            </div>
        }
    </div>
}

@code {
    [Parameter]
    public string OrderId { get; set; } = default!;

    private HubConnection? hubConnection;
    private string currentStatus = "Loading...";
    private List<Notification> notifications = new();

    protected override async Task OnInitializedAsync()
    {
        hubConnection = new HubConnectionBuilder()
            .WithUrl(Navigation.ToAbsoluteUri("/hubs/notifications"))
            .WithAutomaticReconnect()
            .Build();

        hubConnection.On<Notification>("ReceiveNotification", notification =>
        {
            notifications.Add(notification);
            InvokeAsync(StateHasChanged);
        });

        hubConnection.On<string, string>("OrderUpdated", (orderId, status) =>
        {
            if (orderId == OrderId)
            {
                currentStatus = status;
                InvokeAsync(StateHasChanged);
            }
        });

        await hubConnection.StartAsync();
        await hubConnection.InvokeAsync("SubscribeToOrder", OrderId);
    }

    public async ValueTask DisposeAsync()
    {
        if (hubConnection is not null)
        {
            await hubConnection.DisposeAsync();
        }
    }
}

The critical detail is InvokeAsync(StateHasChanged). SignalR callbacks execute on a thread pool thread, not on Blazor's synchronisation context. InvokeAsync marshals the call back to the correct context so the UI re-renders properly.

Extracting a Reusable Service

Rather than connecting in every component, create a service that manages the connection:

Example.cs
public class NotificationService : IAsyncDisposable
{
    private readonly HubConnection _connection;
    public event Action<Notification>? OnNotification;
    public event Action<string, string>? OnOrderUpdated;

    public NotificationService(NavigationManager navigation)
    {
        _connection = new HubConnectionBuilder()
            .WithUrl(navigation.ToAbsoluteUri("/hubs/notifications"))
            .WithAutomaticReconnect()
            .Build();

        _connection.On<Notification>("ReceiveNotification",
            n => OnNotification?.Invoke(n));

        _connection.On<string, string>("OrderUpdated",
            (id, status) => OnOrderUpdated?.Invoke(id, status));
    }

    public async Task StartAsync()
    {
        if (_connection.State == HubConnectionState.Disconnected)
        {
            await _connection.StartAsync();
        }
    }

    public Task SubscribeToOrder(string orderId)
        => _connection.InvokeAsync("SubscribeToOrder", orderId);

    public async ValueTask DisposeAsync()
        => await _connection.DisposeAsync();
}

Register it with the appropriate lifetime:

Example.cs
// For Blazor Server — scoped to the circuit
builder.Services.AddScoped<NotificationService>();

// For Blazor WASM — scoped behaves like singleton
builder.Services.AddScoped<NotificationService>();

Blazor WebAssembly Considerations

In Blazor WASM, the hub connection runs entirely in the browser. A few things to note:

Authentication. Pass the token via AccessTokenProvider:

Example.cs
hubConnection = new HubConnectionBuilder()
    .WithUrl(Navigation.ToAbsoluteUri("/hubs/notifications"), options =>
    {
        options.AccessTokenProvider = async () =>
        {
            var tokenResult = await TokenProvider.RequestAccessToken();
            tokenResult.TryGetToken(out var token);
            return token?.Value;
        };
    })
    .Build();

No background threads. Blazor WASM runs on a single thread. SignalR callbacks execute on the same thread as rendering, so InvokeAsync(StateHasChanged) isn't strictly necessary — but it's still good practice for code that might also run in Blazor Server.

Pushing Updates from the Server

Use IHubContext in your services or API controllers to push updates to Blazor clients:

Example.cs
public class OrderService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hub;

    public OrderService(IHubContext<NotificationHub, INotificationClient> hub)
    {
        _hub = hub;
    }

    public async Task UpdateOrderStatusAsync(string orderId, string status)
    {
        // Update database...

        await _hub.Clients.Group($"order-{orderId}")
            .OrderUpdated(orderId, status);
    }
}

Key Takeaways