Real-time web applications have become essential in modern software development. Users expect instant updates, live notifications, and collaborative features that work without refreshing the page. Blazor Server, combined with SignalR, provides a powerful foundation for building these types of applications in C#.
In this comprehensive guide, we will explore how to build real-time features using Blazor and SignalR. We will start with the fundamentals and progress to advanced patterns that you can use in production applications.
Understanding SignalR in Blazor Server
Blazor Server already uses SignalR under the hood to maintain a persistent connection between the browser and the server. Every UI interaction travels through this SignalR connection, which is why Blazor Server applications feel so responsive. However, you can also create custom SignalR hubs for specific real-time features.
The key difference between the built-in Blazor SignalR connection and custom hubs is purpose. The built-in connection handles UI updates and event handling. Custom hubs let you broadcast messages to specific groups of users, implement chat functionality, or push live data updates.
Setting Up a Basic SignalR Hub
Let us start by creating a simple chat hub. First, create a new hub class:
using Microsoft.AspNetCore.SignalR;
namespace MyApp.Hubs;
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("UserJoined", Context.ConnectionId);
}
public async Task LeaveRoom(string roomName)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).SendAsync("UserLeft", Context.ConnectionId);
}
public async Task SendMessageToRoom(string roomName, string user, string message)
{
await Clients.Group(roomName).SendAsync("ReceiveMessage", user, message);
}
public override async Task OnConnectedAsync()
{
await Clients.All.SendAsync("UserConnected", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
await Clients.All.SendAsync("UserDisconnected", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
Next, register the hub in your Program.cs file:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddSignalR();
var app = builder.Build();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapHub<ChatHub>("/chathub");
app.Run();
Creating the Blazor Chat Component
Now let us build a Blazor component that connects to our hub:
@page "/chat"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@implements IAsyncDisposable
<PageTitle>Real-Time Chat</PageTitle>
<div class="chat-container">
<div class="chat-header">
<h3>Chat Room</h3>
<span class="connection-status @(isConnected ? "connected" : "disconnected")">
@(isConnected ? "Connected" : "Disconnected")
</span>
</div>
<div class="chat-messages" @ref="messagesContainer">
@foreach (var message in messages)
{
<div class="message @(message.User == userName ? "own-message" : "")">
<span class="message-user">@message.User</span>
<span class="message-text">@message.Text</span>
<span class="message-time">@message.Timestamp.ToString("HH:mm")</span>
</div>
}
</div>
<div class="chat-input">
<input @bind="userName" placeholder="Your name" disabled="@isConnected" />
<input @bind="currentMessage"
@onkeypress="HandleKeyPress"
placeholder="Type a message..."
disabled="@(!isConnected)" />
<button @onclick="SendMessage" disabled="@(!isConnected)">Send</button>
</div>
@if (!isConnected)
{
<button @onclick="Connect" class="connect-button">Connect to Chat</button>
}
</div>
@code {
private HubConnection? hubConnection;
private List<ChatMessage> messages = new();
private string userName = "";
private string currentMessage = "";
private bool isConnected = false;
private ElementReference messagesContainer;
private async Task Connect()
{
if (string.IsNullOrWhiteSpace(userName))
{
return;
}
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/chathub"))
.WithAutomaticReconnect()
.Build();
hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
{
messages.Add(new ChatMessage(user, message, DateTime.Now));
InvokeAsync(StateHasChanged);
});
hubConnection.On<string>("UserConnected", (connectionId) =>
{
messages.Add(new ChatMessage("System", $"A user connected", DateTime.Now));
InvokeAsync(StateHasChanged);
});
hubConnection.On<string>("UserDisconnected", (connectionId) =>
{
messages.Add(new ChatMessage("System", $"A user disconnected", DateTime.Now));
InvokeAsync(StateHasChanged);
});
hubConnection.Reconnecting += (exception) =>
{
isConnected = false;
InvokeAsync(StateHasChanged);
return Task.CompletedTask;
};
hubConnection.Reconnected += (connectionId) =>
{
isConnected = true;
InvokeAsync(StateHasChanged);
return Task.CompletedTask;
};
await hubConnection.StartAsync();
isConnected = true;
}
private async Task SendMessage()
{
if (hubConnection is null || string.IsNullOrWhiteSpace(currentMessage))
{
return;
}
await hubConnection.SendAsync("SendMessage", userName, currentMessage);
currentMessage = "";
}
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
await SendMessage();
}
}
public async ValueTask DisposeAsync()
{
if (hubConnection is not null)
{
await hubConnection.DisposeAsync();
}
}
private record ChatMessage(string User, string Text, DateTime Timestamp);
}
Building a Live Dashboard with Real-Time Updates
Chat applications are a common example, but SignalR really shines when building live dashboards. Let us create a stock ticker dashboard that receives real-time price updates:
using Microsoft.AspNetCore.SignalR;
namespace MyApp.Hubs;
public class StockTickerHub : Hub
{
public async Task SubscribeToStock(string symbol)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"stock_{symbol}");
}
public async Task UnsubscribeFromStock(string symbol)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"stock_{symbol}");
}
}
public class StockPrice
{
public string Symbol { get; set; } = "";
public decimal Price { get; set; }
public decimal Change { get; set; }
public decimal ChangePercent { get; set; }
public DateTime Timestamp { get; set; }
}
Now create a background service that simulates stock price updates:
using Microsoft.AspNetCore.SignalR;
namespace MyApp.Services;
public class StockPriceService : BackgroundService
{
private readonly IHubContext<StockTickerHub> _hubContext;
private readonly Dictionary<string, decimal> _prices = new()
{
["AAPL"] = 150.00m,
["GOOGL"] = 2800.00m,
["MSFT"] = 300.00m,
["AMZN"] = 3300.00m,
["TSLA"] = 700.00m
};
private readonly Random _random = new();
public StockPriceService(IHubContext<StockTickerHub> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
foreach (var symbol in _prices.Keys.ToList())
{
var oldPrice = _prices[symbol];
var changePercent = (decimal)(_random.NextDouble() * 2 - 1);
var change = oldPrice * changePercent / 100;
var newPrice = oldPrice + change;
_prices[symbol] = newPrice;
var stockPrice = new StockPrice
{
Symbol = symbol,
Price = Math.Round(newPrice, 2),
Change = Math.Round(change, 2),
ChangePercent = Math.Round(changePercent, 2),
Timestamp = DateTime.UtcNow
};
await _hubContext.Clients
.Group($"stock_{symbol}")
.SendAsync("StockPriceUpdated", stockPrice, stoppingToken);
}
await Task.Delay(1000, stoppingToken);
}
}
}
Register the background service in Program.cs:
builder.Services.AddHostedService<StockPriceService>();
And create the Blazor component for the dashboard:
@page "/stocks"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@implements IAsyncDisposable
<PageTitle>Live Stock Ticker</PageTitle>
<h3>Live Stock Prices</h3>
<div class="stock-grid">
@foreach (var stock in stocks.Values)
{
<div class="stock-card @(stock.Change >= 0 ? "positive" : "negative")">
<div class="stock-symbol">@stock.Symbol</div>
<div class="stock-price">$@stock.Price.ToString("N2")</div>
<div class="stock-change">
@(stock.Change >= 0 ? "+" : "")@stock.Change.ToString("N2")
(@(stock.ChangePercent >= 0 ? "+" : "")@stock.ChangePercent.ToString("N2")%)
</div>
<div class="stock-time">@stock.Timestamp.ToString("HH:mm:ss")</div>
</div>
}
</div>
@code {
private HubConnection? hubConnection;
private Dictionary<string, StockPrice> stocks = new();
private readonly string[] symbols = { "AAPL", "GOOGL", "MSFT", "AMZN", "TSLA" };
protected override async Task OnInitializedAsync()
{
foreach (var symbol in symbols)
{
stocks[symbol] = new StockPrice
{
Symbol = symbol,
Price = 0,
Change = 0,
ChangePercent = 0,
Timestamp = DateTime.Now
};
}
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/stockhub"))
.WithAutomaticReconnect()
.Build();
hubConnection.On<StockPrice>("StockPriceUpdated", (stockPrice) =>
{
stocks[stockPrice.Symbol] = stockPrice;
InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
foreach (var symbol in symbols)
{
await hubConnection.SendAsync("SubscribeToStock", symbol);
}
}
public async ValueTask DisposeAsync()
{
if (hubConnection is not null)
{
foreach (var symbol in symbols)
{
await hubConnection.SendAsync("UnsubscribeFromStock", symbol);
}
await hubConnection.DisposeAsync();
}
}
public class StockPrice
{
public string Symbol { get; set; } = "";
public decimal Price { get; set; }
public decimal Change { get; set; }
public decimal ChangePercent { get; set; }
public DateTime Timestamp { get; set; }
}
}
Implementing Strongly-Typed Hubs
For better type safety and IntelliSense support, you can create strongly-typed hubs. Define an interface for your client methods:
namespace MyApp.Hubs;
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserConnected(string connectionId);
Task UserDisconnected(string connectionId);
Task UserJoined(string connectionId);
Task UserLeft(string connectionId);
Task ReceiveTypingIndicator(string user, bool isTyping);
}
public class TypedChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string message)
{
await Clients.All.ReceiveMessage(user, message);
}
public async Task JoinRoom(string roomName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
await Clients.Group(roomName).UserJoined(Context.ConnectionId);
}
public async Task SendTypingIndicator(string user, bool isTyping)
{
await Clients.Others.ReceiveTypingIndicator(user, isTyping);
}
public override async Task OnConnectedAsync()
{
await Clients.All.UserConnected(Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
await Clients.All.UserDisconnected(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
The strongly-typed approach catches errors at compile time rather than runtime, making your code more maintainable.
Handling Connection State and Reconnection
Production applications need robust connection handling. Here is a pattern for managing connection state:
@code {
private HubConnection? hubConnection;
private ConnectionState connectionState = ConnectionState.Disconnected;
private int reconnectAttempts = 0;
private const int MaxReconnectAttempts = 5;
private async Task InitializeConnection()
{
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/chathub"))
.WithAutomaticReconnect(new[] {
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
})
.Build();
hubConnection.Closed += async (exception) =>
{
connectionState = ConnectionState.Disconnected;
await InvokeAsync(StateHasChanged);
if (reconnectAttempts < MaxReconnectAttempts)
{
await Task.Delay(GetReconnectDelay(reconnectAttempts));
reconnectAttempts++;
await Connect();
}
};
hubConnection.Reconnecting += (exception) =>
{
connectionState = ConnectionState.Reconnecting;
InvokeAsync(StateHasChanged);
return Task.CompletedTask;
};
hubConnection.Reconnected += (connectionId) =>
{
connectionState = ConnectionState.Connected;
reconnectAttempts = 0;
InvokeAsync(StateHasChanged);
return Task.CompletedTask;
};
await Connect();
}
private async Task Connect()
{
if (hubConnection is null) return;
connectionState = ConnectionState.Connecting;
await InvokeAsync(StateHasChanged);
try
{
await hubConnection.StartAsync();
connectionState = ConnectionState.Connected;
reconnectAttempts = 0;
}
catch (Exception)
{
connectionState = ConnectionState.Disconnected;
}
await InvokeAsync(StateHasChanged);
}
private TimeSpan GetReconnectDelay(int attempt)
{
return attempt switch
{
0 => TimeSpan.FromSeconds(1),
1 => TimeSpan.FromSeconds(2),
2 => TimeSpan.FromSeconds(5),
3 => TimeSpan.FromSeconds(10),
_ => TimeSpan.FromSeconds(30)
};
}
private enum ConnectionState
{
Disconnected,
Connecting,
Connected,
Reconnecting
}
}
Scaling SignalR with Redis Backplane
When running multiple server instances, you need a backplane to ensure messages reach all connected clients. Redis is a popular choice:
builder.Services.AddSignalR()
.AddStackExchangeRedis("localhost:6379", options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
});
You will need to install the Microsoft.AspNetCore.SignalR.StackExchangeRedis package:
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
Performance Considerations
When building real-time applications, keep these performance tips in mind:
Use message batching for high-frequency updates. Instead of sending individual updates, collect them and send in batches.
Implement client-side throttling to prevent overwhelming the UI with updates.
Use groups wisely. Adding and removing from groups has overhead, so design your group structure thoughtfully.
Consider using MessagePack for serialization in high-throughput scenarios:
builder.Services.AddSignalR()
.AddMessagePackProtocol();
Conclusion
Blazor and SignalR provide a powerful combination for building real-time web applications entirely in C#. The examples in this guide should give you a solid foundation for implementing chat systems, live dashboards, collaborative tools, and other real-time features.
The key takeaways are:
- Blazor Server already uses SignalR, but custom hubs give you fine-grained control over real-time messaging
- Use groups to organize connections and target specific audiences
- Implement proper connection state management for production reliability
- Consider scaling options like Redis backplane when running multiple instances
- Use strongly-typed hubs for better maintainability
With these patterns, you can build sophisticated real-time applications that provide the responsive experience users expect from modern web applications.