Building Scalable Real-time Applications with Azure SignalR and ASP.NET Core

Real-time features aren't optional anymore—users expect live updates, instant notifications, and collaborative experiences. Azure SignalR Service solves the hardest part of building these features: scaling WebSocket connections across multiple servers without managing complex infrastructure yourself.

Why Self-Hosted SignalR Hits a Wall

SignalR has been the go-to solution for real-time communication in .NET applications since its introduction. It abstracts away the complexity of choosing between WebSockets, Server-Sent Events, and long polling, automatically negotiating the best transport for each client.

The challenge appears when you scale horizontally. WebSocket connections are stateful—they stick to the server that accepted them. When you run multiple application instances behind a load balancer, you face a coordination problem: how does server A notify clients connected to server B?

The traditional answer involves Redis backplane configuration, which works but adds operational complexity. You're now managing Redis cluster configuration, monitoring its health, and ensuring it can handle your message throughput. Azure SignalR Service eliminates this entirely by handling all client connections externally while your application servers become lightweight message publishers.

Setting Up Azure SignalR Service

Create an Azure SignalR Service instance through the Azure Portal or CLI. The free tier provides 20 concurrent connections and 20,000 messages per day—enough for development and small production scenarios.

# Create via Azure CLI
az signalr create \
  --name myapp-signalr \
  --resource-group myapp-rg \
  --sku Free_F1 \
  --service-mode Default

Grab the connection string from the Azure Portal under Settings → Keys. You'll need this to connect your ASP.NET Core application.

Building a Live Dashboard

Let's build something practical: a live analytics dashboard that pushes updates to all connected clients. This pattern applies to monitoring systems, collaborative tools, or any scenario requiring instant data synchronization.

Start with a new ASP.NET Core web application:

dotnet new web -n LiveDashboard
cd LiveDashboard
dotnet add package Microsoft.Azure.SignalR.AspNetCore

Create a hub that defines server-to-client communication methods:

using Microsoft.AspNetCore.SignalR;

namespace LiveDashboard.Hubs;

public class AnalyticsHub : Hub
{
    public async Task SendMetric(string metricName, double value)
    {
        // Broadcast to all connected clients
        await Clients.All.SendAsync("ReceiveMetric", new
        {
            Name = metricName,
            Value = value,
            Timestamp = DateTime.UtcNow
        });
    }

    public async Task JoinDashboard(string dashboardId)
    {
        // Add connection to a specific group
        await Groups.AddToGroupAsync(Context.ConnectionId, dashboardId);
        await Clients.Group(dashboardId).SendAsync("UserJoined", Context.ConnectionId);
    }

    public override async Task OnConnectedAsync()
    {
        await Clients.Caller.SendAsync("Connected", Context.ConnectionId);
        await base.OnConnectedAsync();
    }
}

Configure Azure SignalR in your Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Add SignalR with Azure SignalR Service
builder.Services.AddSignalR().AddAzureSignalR(options =>
{
    options.ConnectionString = builder.Configuration["Azure:SignalR:ConnectionString"];
});

builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.WithOrigins("http://localhost:5173")
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials();
    });
});

var app = builder.Build();

app.UseCors();
app.UseDefaultFiles();
app.UseStaticFiles();

// Map the SignalR hub
app.MapHub<AnalyticsHub>("/hubs/analytics");

app.Run();

Store your connection string in appsettings.json:

{
  "Azure": {
    "SignalR": {
      "ConnectionString": "Endpoint=https://myapp-signalr.service.signalr.net;AccessKey=…"
    }
  }
}

Client-Side Implementation

Create a client that connects to your hub and handles real-time updates. Here's a minimal HTML page with the SignalR JavaScript client:

<!DOCTYPE html>
<html>
<head>
    <title>Live Analytics Dashboard</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/7.0.0/signalr.min.js"></script>
    <style>
        .metric-card {
            border: 1px solid #ddd;
            padding: 20px;
            margin: 10px;
            border-radius: 4px;
        }
        .metric-value {
            font-size: 2em;
            font-weight: bold;
            color: #0078d4;
        }
    </style>
</head>
<body>
    <h1>Live Analytics</h1>
    <div id="status">Connecting…</div>
    <div id="metrics"></div>

    <script>
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/hubs/analytics")
            .withAutomaticReconnect([0, 2000, 10000, 30000])
            .configureLogging(signalR.LogLevel.Information)
            .build();

        connection.on("ReceiveMetric", (metric) => {
            updateMetric(metric.Name, metric.Value, metric.Timestamp);
        });

        connection.on("Connected", (connectionId) => {
            document.getElementById("status").textContent = "Connected";
            console.log("Connected with ID:", connectionId);
        });

        connection.onreconnecting(() => {
            document.getElementById("status").textContent = "Reconnecting…";
        });

        connection.onreconnected(() => {
            document.getElementById("status").textContent = "Connected";
            // Rejoin groups after reconnection
            connection.invoke("JoinDashboard", "main");
        });

        async function start() {
            try {
                await connection.start();
                await connection.invoke("JoinDashboard", "main");
            } catch (err) {
                console.log(err);
                setTimeout(start, 5000);
            }
        }

        function updateMetric(name, value, timestamp) {
            let card = document.getElementById(`metric-`);
            if (!card) {
                card = document.createElement("div");
                card.id = `metric-`;
                card.className = "metric-card";
                document.getElementById("metrics").appendChild(card);
            }
            
            card.innerHTML = `
                <div></div>
                <div class="metric-value"></div>
                <div></div>
            `;
        }

        start();
    </script>
</body>
</html>

The withAutomaticReconnect() configuration is critical for production. It implements exponential backoff, attempting reconnection at 0ms, 2 seconds, 10 seconds, and then every 30 seconds. Networks fail constantly in the real world—this keeps your application connected.

Pushing Updates from Background Services

Often you need to send updates from background jobs or scheduled tasks, not just in response to client requests. Inject IHubContext to send messages from anywhere in your application:

public class MetricsCollectorService : BackgroundService
{
    private readonly IHubContext<AnalyticsHub> _hubContext;
    private readonly ILogger<MetricsCollectorService> _logger;

    public MetricsCollectorService(
        IHubContext<AnalyticsHub> hubContext,
        ILogger<MetricsCollectorService> logger)
    {
        _hubContext = hubContext;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var metrics = await CollectMetrics();
            
            foreach (var metric in metrics)
            {
                await _hubContext.Clients.All.SendAsync(
                    "ReceiveMetric",
                    new
                    {
                        metric.Name,
                        metric.Value,
                        Timestamp = DateTime.UtcNow
                    },
                    stoppingToken);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }

    private async Task<IEnumerable<Metric>> CollectMetrics()
    {
        // Simulate collecting metrics
        return new[]
        {
            new Metric { Name = "ActiveUsers", Value = Random.Shared.Next(100, 500) },
            new Metric { Name = "RequestsPerSecond", Value = Random.Shared.Next(10, 100) },
            new Metric { Name = "AverageResponseTime", Value = Random.Shared.Next(50, 200) }
        };
    }
}

public record Metric
{
    public string Name { get; init; } = string.Empty;
    public double Value { get; init; }
}

Register the background service in Program.cs:

builder.Services.AddHostedService<MetricsCollectorService>();

This service runs continuously, collecting and broadcasting metrics every five seconds to all connected clients.

Scaling with Groups and User Targeting

Broadcasting to everyone works for simple dashboards, but production applications need granular control. SignalR provides groups for logical collections of connections and user-based targeting for personal notifications.

Send messages to specific users:

[Authorize]
public class NotificationHub : Hub
{
    private readonly IUserIdProvider _userIdProvider;

    public async Task SendPersonalNotification(string userId, string message)
    {
        await Clients.User(userId).SendAsync("ReceiveNotification", new
        {
            Message = message,
            Timestamp = DateTime.UtcNow
        });
    }

    public async Task BroadcastToRole(string role, string message)
    {
        // Send to all users in a specific role
        await Clients.Group($"role_{role}").SendAsync("ReceiveNotification", new
        {
            Message = message,
            Timestamp = DateTime.UtcNow
        });
    }

    public override async Task OnConnectedAsync()
    {
        // Add user to role-based groups
        var userRoles = Context.User?.Claims
            .Where(c => c.Type == ClaimTypes.Role)
            .Select(c => c.Value);

        if (userRoles != null)
        {
            foreach (var role in userRoles)
            {
                await Groups.AddToGroupAsync(Context.ConnectionId, $"role_{role}");
            }
        }

        await base.OnConnectedAsync();
    }
}

Implement a custom user ID provider to map connections to your user identities:

public class EmailBasedUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst(ClaimTypes.Email)?.Value;
    }
}

Register it in Program.cs:

builder.Services.AddSingleton<IUserIdProvider, EmailBasedUserIdProvider>();

Authentication and Security

Never expose unauthenticated SignalR hubs in production. Use standard ASP.NET Core authentication:

[Authorize]
public class SecureHub : Hub
{
    [Authorize(Roles = "Admin")]
    public async Task BroadcastToAll(string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", message);
    }

    public async Task SendToSelf(string message)
    {
        await Clients.Caller.SendAsync("ReceiveMessage", message);
    }
}

Configure JWT authentication on the client:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/secure", {
        accessTokenFactory: () => {
            return localStorage.getItem("authToken");
        }
    })
    .withAutomaticReconnect()
    .build();

The token factory is called for each connection attempt, ensuring fresh tokens after reconnection.

Performance Considerations

Azure SignalR Service charges based on message units (1 unit = 2KB). Keep messages lean:

// Instead of sending entire objects
// await Clients.All.SendAsync("DataUpdate", largeObject);

// Send only what changed
await Clients.All.SendAsync("DataUpdate", new
{
    Id = data.Id,
    ModifiedFields = new[] { "Status", "UpdatedAt" }
});

For high-frequency updates, implement throttling:

private DateTime _lastBroadcast = DateTime.MinValue;
private readonly TimeSpan _broadcastInterval = TimeSpan.FromMilliseconds(100);

public async Task SendMetricWithThrottling(string name, double value)
{
    if (DateTime.UtcNow - _lastBroadcast < _broadcastInterval)
        return;

    _lastBroadcast = DateTime.UtcNow;
    await Clients.All.SendAsync("ReceiveMetric", name, value);
}

This prevents overwhelming clients with updates they can't process while reducing your Azure SignalR costs.

Monitoring and Troubleshooting

Monitor your Azure SignalR Service through Azure Portal metrics. Watch for:

  • Connection count trends
  • Message throughput
  • Server load percentage
  • Connection errors

Enable Application Insights integration to track SignalR-specific telemetry:

builder.Services.AddApplicationInsightsTelemetry();

builder.Services.AddSignalR()
    .AddAzureSignalR(options =>
    {
        options.ConnectionString = builder.Configuration["Azure:SignalR:ConnectionString"];
    })
    .AddApplicationInsights();

This automatically tracks hub invocations, connection lifetimes, and errors in Application Insights.

When to Use Azure SignalR vs Self-Hosted

Use Azure SignalR Service when:

  • You need to scale beyond a single server
  • You want to avoid managing Redis infrastructure
  • Connection count is unpredictable or bursty
  • You need global distribution

Stick with self-hosted SignalR when:

  • You have a single application instance
  • Your connection count stays under 100
  • You already have Redis infrastructure
  • You need complete control over the transport layer

For most production scenarios, Azure SignalR Service simplifies operations significantly. The free tier gets you started, and scaling is transparent as your application grows.

Key Takeaways

Azure SignalR Service removes the complexity of scaling real-time applications. Your code remains simple—the service handles connection management, message routing, and horizontal scaling.

Always implement automatic reconnection on clients. Networks are unreliable, and seamless reconnection is what separates professional applications from prototypes.

Use groups for targeted messaging instead of broadcasting to everyone. This reduces bandwidth, improves performance, and gives you fine-grained control over who receives updates.

Authenticate your hubs before production deployment. SignalR integrates naturally with ASP.NET Core authentication—there's no excuse for anonymous connections.

Monitor your message sizes and throughput. Azure SignalR pricing scales with usage, making efficient messaging both a performance and cost optimization.

Start simple with broadcast messaging, add groups when you need segmentation, and implement authentication from the beginning. The patterns shown here provide production-ready foundations for building real-time features your users expect.