If your users still hit F5 to see updates, you’re leaving delight on the table. In this post I’ll show you how to make your Blazor app feel alive with SignalR – from a zero‑to‑chat demo to production‑ready patterns like groups, background broadcasts, auth, and scaling. We’ll build it step by step with clear C# code you can paste into a fresh project.
Quick mental model: how SignalR fits Blazor
- SignalR is ASP.NET Core’s real‑time stack. It abstracts WebSockets (with long‑polling/server‑sent events fallbacks) and gives you Hubs – C# classes where clients call server methods and the server pushes messages to clients.
- Blazor Server already uses SignalR under the hood for UI diffs. You can still add your own hubs for cross‑user features (chat, toasts, live dashboards). They don’t conflict.
- Blazor WebAssembly runs C# in the browser. It connects to your hub either with the .NET SignalR client or the JS client – your choice.

Keep that in mind and the rest clicks into place.
Blazor Server + SignalR: the minimal chat
We’ll create a hub, map it, then connect from a Blazor component (using a tiny JS bridge) and broadcast messages.

1) Create the project (.NET 8)
# New app
dotnet new blazorserver -n RealTimeBasics
cd RealTimeBasics
2) Add a Hub
Hubs are where clients call
SendMessage
and where the server pushesReceiveMessage
.
/Hubs/ChatHub.cs
using Microsoft.AspNetCore.SignalR;
namespace RealTimeBasics.Hubs;
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
{
// Broadcast to everyone
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
}
3) Wire it up
Program.cs
using RealTimeBasics.Hubs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSignalR(); // your custom hubs
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.MapBlazorHub(); // Blazor circuit hub
app.MapHub<ChatHub>("/chathub"); // Your chat hub
app.MapFallbackToPage("/_Host");
app.Run();
4) Install the JS client (one line)
You can use a CDN (quickest):
Pages/_Host.cshtml (right before </body>
)
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
<script src="/js/chat.js"></script>
Create wwwroot/js/chat.js (tiny bridge between SignalR and your component):
window.chat = (function () {
let connection;
let dotnetRef;
async function start(url) {
connection = new signalR.HubConnectionBuilder()
.withUrl(url)
.withAutomaticReconnect()
.build();
connection.on("ReceiveMessage", (user, message) => {
// Call into .NET to append the message
if (dotnetRef) dotnetRef.invokeMethodAsync("AddMessage", user, message);
});
await connection.start();
return true;
}
function register(dotnetObjectRef) { dotnetRef = dotnetObjectRef; }
async function send(user, message) {
if (!connection) throw "connection not started";
await connection.invoke("SendMessage", user, message);
}
return { start, send, register };
})();
5) The Blazor component (UI + .NET ↔ JS interop)
Pages/Chat.razor
@page "/chat"
@inject IJSRuntime JS
<h3>SignalR Chat</h3>
<div class="mb-2">
<input class="form-control" placeholder="Your name" @bind="user" />
</div>
<div class="mb-2">
<input class="form-control" placeholder="Type a message and hit Enter" @bind="text" @onkeydown="OnKeyDown" />
</div>
<button class="btn btn-primary" @onclick="Send">Send</button>
<ul class="mt-3 list-group">
@foreach (var m in messages)
{
<li class="list-group-item"><b>@m.User:</b> @m.Content</li>
}
</ul>
@code {
private string user = $"user-{Guid.NewGuid().ToString()[..4]}";
private string text = string.Empty;
private readonly List<(string User, string Content)> messages = new();
private DotNetObjectReference<Chat>? selfRef;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
selfRef = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("chat.register", selfRef);
await JS.InvokeAsync<bool>("chat.start", "/chathub");
}
}
private async Task Send()
{
if (!string.IsNullOrWhiteSpace(text))
{
await JS.InvokeVoidAsync("chat.send", user, text);
text = string.Empty;
}
}
private async Task OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") await Send();
}
[JSInvokable]
public Task AddMessage(string u, string content)
{
messages.Add((u, content));
StateHasChanged();
return Task.CompletedTask;
}
public void Dispose()
{
selfRef?.Dispose();
}
}
Try it: run, open two browser windows at /chat
, and send messages – everyone sees them instantly.
Why JS here? In Blazor Server your C# runs on the server. The browser needs to keep a SignalR client connection; the easiest is the JS client plus a thin interop layer.
Groups (rooms) in 5 minutes
Rooms help you isolate broadcasts (e.g., a support ticket room). We’ll add JoinRoom
and SendToRoom
.
/Hubs/ChatHub.cs (extend)
public class ChatHub : Hub
{
public async Task JoinRoom(string room)
=> await Groups.AddToGroupAsync(Context.ConnectionId, room);
public async Task LeaveRoom(string room)
=> await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
public async Task SendToRoom(string room, string user, string message)
=> await Clients.Group(room).SendAsync("ReceiveMessage", user, message);
}
wwwroot/js/chat.js (add helpers)
async function join(room){ await connection.invoke("JoinRoom", room); }
async function leave(room){ await connection.invoke("LeaveRoom", room); }
return { start, send, register, join, leave };
Pages/Chat.razor (room UI)
<select class="form-select w-auto d-inline" @onchange="OnRoomChanged">
@foreach (var r in rooms)
{
<option value="@r" selected="@(r==room)">@r</option>
}
</select>
@code {
private string room = "general";
private string[] rooms = new[] { "general", "support", "random" };
private async Task OnRoomChanged(ChangeEventArgs e)
{
var newRoom = e.Value?.ToString() ?? "general";
await JS.InvokeVoidAsync("chat.leave", room);
room = newRoom;
await JS.InvokeVoidAsync("chat.join", room);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
selfRef = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("chat.register", selfRef);
await JS.InvokeAsync<bool>("chat.start", "/chathub");
await JS.InvokeVoidAsync("chat.join", room);
}
}
private async Task Send()
{
if (!string.IsNullOrWhiteSpace(text))
{
await JS.InvokeVoidAsync("chat.send", user, $"[{room}] {text}");
text = string.Empty;
}
}
}
Now messages stay inside the chosen room.
Server‑initiated push (background ticker & notifications)
In real apps, the server often pushes events: counters, alerts, progress, etc. Use IHubContext<T>
from any service.
Services/ServerTicker.cs
using Microsoft.AspNetCore.SignalR;
using RealTimeBasics.Hubs;
public class ServerTicker : BackgroundService
{
private readonly IHubContext<ChatHub> _hub;
private readonly ILogger<ServerTicker> _log;
public ServerTicker(IHubContext<ChatHub> hub, ILogger<ServerTicker> log)
{ _hub = hub; _log = log; }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var i = 0;
while (!stoppingToken.IsCancellationRequested)
{
try
{
await _hub.Clients.All.SendAsync("ReceiveMessage", "server", $"tick {++i}", stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (TaskCanceledException) { }
catch (Exception ex) { _log.LogError(ex, "ticker error"); }
}
}
}
Program.cs (register)
builder.Services.AddHostedService<ServerTicker>();
Open /chat
, wait a few seconds you’ll see server: tick N
messages roll in automatically.
Blazor WebAssembly variant (no JS needed)
If you’re building Blazor WASM, you can connect from C# directly using the .NET SignalR client.
Shared Hub (same as before):
public class ChatHub : Hub
{
public async Task SendMessage(string user, string message)
=> await Clients.All.SendAsync("ReceiveMessage", user, message);
}
Server (map hub):
app.MapHub<ChatHub>("/chathub");
WASM Client (e.g., Client/Pages/Chat.razor
):
@page "/chat"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Nav
<h3>SignalR Chat (WASM)</h3>
<input @bind="user" />
<input @bind="text" @onkeydown="OnKeyDown" />
<button @onclick="Send">Send</button>
<ul>
@foreach (var m in messages)
{ <li><b>@m.User</b>: @m.Content</li> }
</ul>
@code {
private HubConnection? hub;
private string user = $"user-{Guid.NewGuid().ToString()[..4]}";
private string text = string.Empty;
private readonly List<(string User, string Content)> messages = new();
protected override async Task OnInitializedAsync()
{
hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/chathub"))
.WithAutomaticReconnect()
.Build();
hub.On<string, string>("ReceiveMessage", (u, msg) =>
{
messages.Add((u, msg));
InvokeAsync(StateHasChanged);
});
await hub.StartAsync();
}
private async Task Send()
{
if (!string.IsNullOrWhiteSpace(text) && hub is not null)
{
await hub.InvokeAsync("SendMessage", user, text);
text = string.Empty;
}
}
private async Task OnKeyDown(KeyboardEventArgs e)
{ if (e.Key == "Enter") await Send(); }
}
Package needed in Client:
Microsoft.AspNetCore.SignalR.Client
.
This variant is pure C# end‑to‑end – handy if you want to avoid JS in WASM.
Authentication & authorization
Often you only want authenticated users on a hub or want to target users/roles.
Secure a hub
using Microsoft.AspNetCore.Authorization;
[Authorize]
public class ChatHub : Hub { /* ... */ }
Add normal ASP.NET Core auth (cookies/Identity/OpenID Connect). The hub now rejects unauthenticated connections.
Send to a user or role
await Clients.User(userId).SendAsync("ReceiveMessage", "system", "private ping");
await Clients.Users(userIds).SendAsync("ReceiveMessage", "system", "multicast");
await Clients.Groups("admins").SendAsync("ReceiveMessage", "system", "admin notice");
Get the current user
var name = Context.User?.Identity?.Name;
Map identities to groups (on connect)
public override async Task OnConnectedAsync()
{
if (Context.User?.IsInRole("Admin") == true)
await Groups.AddToGroupAsync(Context.ConnectionId, "admins");
await base.OnConnectedAsync();
}
Reliability: reconnects & transient failures
.withAutomaticReconnect()
(JS and .NET clients) retries with back‑off.- In the browser, teach your UI to disable Send while disconnected.
- Use cancellation tokens when pushing from background services.
- For long‑running hub methods, prefer async and avoid blocking.
Example: show connection state (JS client)
connection.onreconnecting(() => dotnetRef.invokeMethodAsync("SetStatus", "reconnecting"));
connection.onreconnected(() => dotnetRef.invokeMethodAsync("SetStatus", "connected"));
connection.onclose(() => dotnetRef.invokeMethodAsync("SetStatus", "closed"));
[JSInvokable] public Task SetStatus(string s) { status = s; StateHasChanged(); return Task.CompletedTask; }
Performance & scaling checklists
Server efficiency
- Keep hub methods thin; push heavy work to background services/queues.
- Avoid per‑message database round‑trips. Batch or cache where possible.
- Prefer structured payloads (DTOs) over large strings.
Blazor Server specifics
- Each user holds a circuit (and an underlying SignalR connection). For multi‑thousand concurrents use sticky sessions or offload to Azure SignalR Service.
Horizontal scale options
- Redis backplane (multi‑node broadcast):
builder.Services
.AddSignalR()
.AddStackExchangeRedis("<redis-connection-string>");
- Azure SignalR Service (managed scale, offloads connections):
builder.Services.AddSignalR().AddAzureSignalR();
app.UseRouting();
app.MapHub<ChatHub>("/chathub");
// In Program.cs for Azure: app.UseAzureSignalR(routes => routes.MapHub<ChatHub>("/chathub"));
Pick Redis when you manage your own servers and want simple fan‑out; pick Azure SignalR when you want no‑brainer scale and global POPs.
Payload hygiene
- Consider message compression for chat/log‑like streams.
- Coalesce frequent events (e.g., progress every 200ms instead of 10ms).
Testing & troubleshooting
- Open two private windows; verify messages flow both ways.
- Use browser devtools → Network → WS to see the WebSocket frames.
- Enable logs:
builder.Logging.AddConsole();
- In JS client:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/chathub")
.configureLogging(signalR.LogLevel.Information)
.build();
- If you see 404 on
/chathub
, confirmapp.MapHub<ChatHub>("/chathub")
and script order. - If messages don’t render in Blazor, ensure you call
StateHasChanged()
after appending.
Common mistakes (and fast fixes)
- Using .NET SignalR client in Blazor Server UI: remember your UI C# runs on the server. The browser needs a client – use the JS client + interop (as shown).
- Forgetting sticky sessions when scaling Blazor Server: users get disconnected on load‑balanced hops. Enable affinity (e.g., ARR/NGINX cookies) or Azure SignalR.
- Broadcasting inside hub constructors: the DI container might create hubs per connection – don’t hold state there. Use services +
IHubContext<T>
instead. - Blocking calls in hub methods: use async all the way; never
.Result
or.Wait()
. - Huge payloads: switch to DTOs and compress/limit frequency.
FAQ: your top SignalR + Blazor questions
Blazor Server uses SignalR for UI diffs, but for cross‑user events (chat, notifications, live dashboards) you still use a custom hub or a service that pushes via IHubContext<T>
.
Both work. The .NET client keeps you in C#, great for shared code/models. The JS client is lighter and sometimes easier if you already have JS modules
No. SignalR negotiates the best transport. WebSockets is preferred; it’ll fall back if proxies block it.
Yes. Create DTOs and use hub.On<YourDto>(...)
on the client; SendAsync("Receive", dto)
on the server. Consider AddJsonProtocol
for custom serialization options.
Inject IHubContext<ChatHub>
anywhere. It lets you call Clients.All/Group/User
without a hub instance.
Put users into tenant‑named groups on connect; always target that group. Optionally validate tenant claims inside hub methods.
Don’t. Use regular uploads/APIs for blobs and push events (e.g., “file ready at URL”). Keep hub payloads small.
Conclusion: Ship delight with a tiny diff
Blazor + SignalR makes real‑time boringly easy. We created a working chat, added rooms, pushed server events, secured hubs, and discussed scaling. The leap from a static app to a living app is a handful of lines.
Add SignalR to a non‑critical page in your project (e.g., live notifications), run it past a few users, and watch the feedback. Want me to expand this into a starter repo or add Azure SignalR Terraform? Drop a comment – let’s build it together.