Blazor SignalR Basics: Creating Real-Time Web Apps Easily

Blazor SignalR Basics: Build Real‑Time Apps in C#

Build real‑time chat, notifications, and dashboards with Blazor and SignalR. Step‑by‑step C# examples for Server and WebAssembly.

.NET Development·By amarozka · September 13, 2025

Blazor SignalR Basics: Build Real‑Time Apps in C#

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.
Real-Time Workflow with Blazor Server and SignalR

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.

Blazor Chat Flow

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 pushes ReceiveMessage.

/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, confirm app.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

Do I even need SignalR if I’m on Blazor Server?

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>.

What about WebAssembly – JS client or .NET client?

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

Is WebSockets mandatory?

No. SignalR negotiates the best transport. WebSockets is preferred; it’ll fall back if proxies block it.

Can I send strongly‑typed messages?

Yes. Create DTOs and use hub.On<YourDto>(...) on the client; SendAsync("Receive", dto) on the server. Consider AddJsonProtocol for custom serialization options.

How do I push from controllers or background jobs?

Inject IHubContext<ChatHub> anywhere. It lets you call Clients.All/Group/User without a hub instance.

How do I secure messages per tenant?

Put users into tenant‑named groups on connect; always target that group. Optionally validate tenant claims inside hub methods.

How do I send files or large streams?

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.

Leave a Reply

Your email address will not be published. Required fields are marked *