The Scenario
You are building a Blazor WebAssembly app that needs real-time notifications (status updates, alerts, chat messages, etc.) and it’s hosted on a multi-instance Azure App Service. For this setup, I recommend using Azure SignalR Service instead of standard SignalR for better scalability and reliability.
Key Requirements
- Real-time notifications in a multi-instance environment
- User-specific notifications (when user X triggers an activity, only user X receives the related notifications)
- Secure identification using Azure AD Object ID (Oid) through a custom
IUserIdProvider
This approach ensures your real-time functionality scales properly across multiple instances while maintaining security and user-specific targeting.
HTTP VS WEB-SOCKETS

HTTP and WebSocket are both ways for computers to talk to each other, but they work in different ways.
- HTTP is used for simple requests, where a client sends a request and the server replies, then the connection is closed.
- WebSocket keeps the connection open, allowing for real-time web functionality. Real-time web functionality is the ability to have server code push content to connected clients instantly as it becomes available, rather than having the server wait for a client to request new data, making it great for things like live chats or online games where constant updates are needed.
SignalR uses the new WebSocket transport where available and falls back to older transports where necessary. Using SignalR means that a lot of the extra functionality needed to implement web sockets is already done. SignalR also shields developers from having to worry about updates to WebSocket, since SignalR is updated to support changes in the underlying transport, providing the application a consistent interface across versions of WebSocket.
Do you need SignalR?
Use SignalR if the following apply to you:
✅ Users need to refresh pages to see new data,
✅ Pages implements long polling to retrieve new data
✅ Collaborative applications (such as simultaneous editing of documents)
✅ Job progress updates
✅ Application requires high frequency updates from the server
STANDARD SIGNALR | STANDARD + REDIS | AZURE SIGNALR | |
CONNECTION STORAGE | Local server memory | Local + Redis sync | Azure service |
SCALING | Single server only | Multi-server | Automatic |
SETUP COMPLEXITY | Simple | Complex (Redis setup) | Simple |
CONNECTION MANAGEMENT | Self handled | Self + Redis handle | Azure handles |
MESSAGE ROUTING | Local only | Via Redis | Via Azure |
Why use Azure SignalR for real-time notifications in a multi-instance environment?
Multiple Users, Multiple Server Instances – No Problem!
Server 1 ↘
Server 2 → Azure SignalR Service ← Handles ALL user connections
Server 3 ↗
All instances can message any user through Azure
No Redis backplane needed
Azure SignalR Service:
├── Connection Manager → Tracks all WebSocket connections
├── Message Router → Routes messages to correct connections
├── Scale Units → Handles thousands of concurrent connections
└── Geographic Distribution → Multiple regions
Provision Azure SignalR Service via Terraform
main-signal.tf
resource "azurerm_signalr_service" "signalr" {
name = "signalr-service"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
sku {
name = "Standard_S1"
capacity = 1
}
cors {
allowed_origins = ["https://your-blazor-frontend.com"]
}
service_mode = "Default"
connectivity_logs_enabled = true
messaging_logs_enabled = true
tls_client_cert_enabled = false
}
Define a Hub
TaskHub.cs
public class TaskHub : Hub
{
/// <summary>
/// Client calls this immediately after triggering a task to receive updates.
/// </summary>
/// <param name="runId">The unique identifier of the task run.</param>
public Task SubscribeToRun(string runId)
{
return Groups.AddToGroupAsync(Context.ConnectionId, runId);
}
/// <summary>
/// Client can call this to stop receiving updates for a run.
/// </summary>
/// <param name="runId">The unique identifier of the task run.</param>
public Task UnsubscribeFromRun(string runId)
{
return Groups.RemoveFromGroupAsync(Context.ConnectionId, runId);
}
}
Establish single user notifications
OidUserIdProvider.cs
public class OidUserIdProvider : IUserIdProvider
{
public string GetUserId(HubConnectionContext connection)
{
var oidClaim = connection.User?.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")
?? connection.User?.FindFirst("oid");
return oidClaim?.Value!;
}
}
Create an Azure SignalR service
AzureSignalRService.cs
public class AzureSignalRService : IAsyncDisposable
{
private readonly ILogger<AzureSignalRService> _logger;
private readonly Task<ServiceHubContext> _hubContextTask;
public AzureSignalRService(
ServiceManager serviceManager,
ILogger<AzureSignalRNotificationService> logger)
{
_logger = logger;
_hubContextTask = serviceManager.CreateHubContextAsync("TaskHub", CancellationToken.None);
}
public async Task SendStatusUpdateAsync(string userOid, string taskId, string status, string message)
{
try
{
var hubContext = await _hubContextTask;
await hubContext.Clients.User(userOid).SendAsync("StatusUpdate", taskId, status, message);
_logger.LogInformation("Sent status update to user {UserOid} for task {TaskId}", userOid, taskId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send SignalR notification to user {UserOid}", userOid);
}
}
public async Task NotifyGroupAsync(string groupId, string method, params object[] args)
{
try
{
var hubContext = await _hubContextTask;
await hubContext.Clients.Group(groupId).SendAsync(method, args);
_logger.LogInformation("Sent notification to group {GroupId}", groupId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send SignalR notification to group {GroupId}", groupId);
}
}
public async ValueTask DisposeAsync()
{
try
{
var hubContext = await _hubContextTask;
await hubContext.DisposeAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing hub context");
}
GC.SuppressFinalize(this);
}
}
Register in the program.cs
var signalRConnectionString = configuration["AzSignalR:ConnectionString"];
if (!string.IsNullOrEmpty(signalRConnectionString))
{
services.AddSignalR().AddAzureSignalR(signalRConnectionString);
}
else
{
services.AddSignalR(); // Use in-memory SignalR for tests
}
/*
Register ServiceManager (REQUIRED)
Hub Context Creation: AzureSignalRNotificationService uses _serviceManager.CreateHubContextAsync()
Azure Connection: ServiceManager handles the connection to Azure SignalR Service
Management SDK: It's the entry point for the Azure SignalR Management SDK
*/
services.AddSingleton<ServiceManager>(_ =>
{
return new ServiceManagerBuilder()
.WithOptions(option =>
{
option.ConnectionString = signalRConnectionString;
})
.BuildServiceManager();
});
services.AddSingleton<AzureSignalRService>();
services.AddSingleton<IUserIdProvider, OidUserIdProvider>();
app.MapHub<TaskHub>("/taskHub");
Why register as Singleton?
- Singleton – One instance of a resource, reused anytime it’s requested.
- Scoped – One instance of a resource, but only for the current request. New request (i.e. hit
an API endpoint again) = new instance - Transient – A different instance of a resource, every-time it’s requested.
Register the backend service as a singleton as ensures that the hub context created once and reused forever. This leads to better performance (no recreation overhead), lower memory usage and no user-specific state to worry about.In general, SignalR hub contexts are designed to be long-lived.
If you register it as scoped then, it will quite expensive as a hub context would be recreated per request, leading to unnecessary disposal/recreation cycles, higher memory pressure and lower response times due to initialization costs.
Overall, this service is nothing more than a message sender, not a connection manager: Each user has their own WebSocket
connection to Azure SignalR and this mapping happens in Azure SignalR, not in this service!
Frontend
TaskNotificationService.cs
public class TaskNotificationService : IAsyncDisposable
{
private readonly HubConnection _hub;
private readonly ILogger<TaskNotificationService> _logger;
private readonly HashSet<string> _subscribedTasks = [];
public event Func<string, string, string?, Task>? OnStatusUpdate;
public TaskNotificationService(NavigationManager nav, ILogger<TaskNotificationService> logger)
{
_logger = logger;
_hub = new HubConnectionBuilder()
.WithUrl(nav.ToAbsoluteUri("/taskHub"))
.WithAutomaticReconnect([
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30),
TimeSpan.FromMinutes(1)
])
.WithServerTimeout(TimeSpan.FromMinutes(2))
.WithKeepAliveInterval(TimeSpan.FromSeconds(15))
.Build();
_hub.On<string, string, string?>("StatusUpdate", async (runId, status, message) =>
{
if (_subscribedTasks.Contains(runId))
{
_logger.LogInformation("Received status update for subscribed task {RunId}: {Status}", runId, status);
if (OnStatusUpdate != null)
await OnStatusUpdate.Invoke(runId, status, message);
}
else
{
_logger.LogDebug("Ignoring status update for unsubscribed task {RunId}", runId);
}
});
_hub.Reconnected += async (connectionId) =>
{
_logger.LogInformation("Azure Notifications reconnected with ID: {ConnectionId}", connectionId);
// Re-subscribe to all tasks
foreach (var taskId in _subscribedTasks.ToList())
{
await _hub.InvokeAsync("SubscribeToRun", taskId);
_logger.LogInformation("Re-subscribed to task {taskId} via Azure Notifications", taskId);
}
};
_hub.Closed += async (error) =>
{
_logger.LogWarning("Azure Notifications connection closed: {Error}", error?.Message);
await Task.Delay(Random.Shared.Next(0, 2) * 1000);
await StartAsync();
};
}
public async Task StartAsync()
{
if (_hub.State == HubConnectionState.Disconnected)
{
try
{
await _hub.StartAsync();
_logger.LogInformation("Azure SignalR connection started successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start Azure SignalR connection");
throw;
}
}
}
public async Task StopAsync()
{
if (_hub.State == HubConnectionState.Connected)
{
// Unsubscribe from all tasks before stopping
foreach (var taskId in _subscribedTasks.ToList())
{
await UnsubscribeFromRunAsync(taskId);
}
await _hub.StopAsync();
_logger.LogInformation("Azure SignalR connection stopped");
}
}
public async Task SubscribeToRunAsync(string runId)
{
if (_hub.State == HubConnectionState.Connected)
{
await _hub.InvokeAsync("SubscribeToRun", runId);
_subscribedTasks.Add(runId);
_logger.LogInformation("Subscribed to task {RunId} via Azure SignalR", runId);
}
else
{
_logger.LogWarning("Cannot subscribe to task {RunId} - Azure SignalR not connected", runId);
}
}
public async Task UnsubscribeFromRunAsync(string runId)
{
if (_hub.State == HubConnectionState.Connected)
{
await _hub.InvokeAsync("UnsubscribeFromRun", runId);
}
_subscribedTasks.Remove(runId);
_logger.LogInformation("Unsubscribed from task {RunId} via Azure SignalR", runId);
}
public async ValueTask DisposeAsync()
{
await StopAsync();
await _hub.DisposeAsync();
_logger.LogInformation("TaskNotificationService disposed");
}
}
builder.Services.AddScoped<TaskNotificationService>();
This service is registered as scoped on the frontend as there are user-specific subscriptions, user-specific connections and user-specific event handling.
Weather.razor
@page "/weather"
@inject HttpClient Http
@inject TaskNotificationService Notifier
@implements IAsyncDisposable
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>Temp. (C)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
<button class="btn btn-primary" @onclick="StartTask" disabled="@_isRunning">
@(_isRunning ? "Running..." : "Start Task")
</button>
@if (!string.IsNullOrEmpty(_status))
{
<div class="alert alert-info mt-3">@_status</div>
}
@code {
private WeatherForecast[]? forecasts;
private string? _taskId;
private bool _isRunning;
private string? _status;
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
Notifier.OnStatusUpdate += HandleStatusUpdate;
await Notifier.StartAsync();
}
private async Task StartTask()
{
try
{
_isRunning = true;
// Trigger the backend task
var response = await Http.PostAsync("api/weather/process", null);
var taskId = await response.Content.ReadFromJsonAsync<string>();
if (string.isNullOrEmpty(taskId))
{
_taskId = taskId;
await Notifier.SubscribeToRunAsync(_taskId);
_status = $"Task {_taskId} started, waiting for updates...";
}
else
{
_status = "Failed to start task";
_isRunning = false;
}
}
catch (Exception ex)
{
_status = $"Error: {ex.Message}";
_isRunning = false;
}
StateHasChanged();
}
private async Task HandleStatusUpdate(string runId, string status, string? message)
{
_status = $"Status: {status} - {message}";
if (status == "completed" || status == "failed")
{
_isRunning = false;
await Notifier.UnsubscribeFromRunAsync(runId);
}
StateHasChanged();
}
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
}
}
Conclusion
Whilst you could use Redis backplane to handle the multi-instance App Service self-hosted SignalR, I chose to be lazy and went with Azure SignalR instead. Like my former professor would say, “a lazy programmer is a good programmer”.
So in the end, the flow becomes:
- The Blazor WASM app authenticates with Azure AD and receives an access token containing the user’s Oid.
- The client establishes a SignalR connection to Azure SignalR Service, passing the Azure AD token.
- Azure SignalR Service routes the connection to one of the App Service instances.
- The backend uses
OidUserIdProvider
to extract the OID from the token and map the connection to the user. - Backend services (e.g., triggered by webhooks or events) send notifications via
IHubContext
, targeting users by OID. - Notifications are routed through Azure SignalR Service and pushed to the correct client(s).
- This architecture guarantees secure, scalable, and user-specific real-time notifications for your Blazor WebAssembly application.
In case you need a visual diagram – here’s what Claude thinks

Pingback: Implementation and deployment of Azure AD App-to-App Authentication Between Azure Functions and Blazor WebAssembly – Azure, C#, Terraform, YAML