Skip to main content

ASP.NET Core SignalR Tutorial: Live Chat & Notifications

Learn ASP.NET Core SignalR step by step: build real-time chat and live notifications in C# with hubs, groups, auth, and scaling. Start coding today!

Users expect apps to update on their own. A chat message should show up as soon as it is sent. An order status should change without a page refresh. If your app still polls the server every five seconds, you are wasting bandwidth and your users can feel the delay. ASP.NET Core SignalR is Microsoft's built-in library for real-time web features in C#. It lets the server push data to connected clients as soon as something happens.

In this SignalR tutorial you will build two common real-time features: a multi-room chat application and user-targeted live notifications. The examples use .NET 10 and cover strongly typed hubs, groups, authentication, sending messages from background services, and scaling out. Along the way we explain why each piece works the way it does, so you can avoid the mistakes that tend to show up in production.

What Is ASP.NET Core SignalR and Why Use It?

SignalR is an abstraction over real-time transports. When a client connects, SignalR negotiates the best transport that both sides support:

  • WebSockets: full-duplex and low-latency. This is the preferred transport.
  • Server-Sent Events (SSE): a server-to-client stream, used when WebSockets are unavailable.
  • Long Polling: the last-resort fallback, which works almost everywhere.

You could write raw WebSocket code yourself. You would then also have to handle connection management, reconnection, message framing, serialization, grouping, and fallback transports. SignalR does all of that for you. You get an RPC-style programming model: the client calls a C# method on the server, and the server calls a JavaScript (or .NET, Java, Swift) function on the client.

When SignalR is the right choice

  • Chat and collaboration apps
  • Live dashboards, monitoring, and stock tickers
  • Notifications and alerts
  • Multiplayer games and live auctions
  • Progress reporting for long-running jobs

If you only need occasional one-way updates, plain SSE or even polling may be enough. SignalR is most useful when updates are frequent, bidirectional, or targeted at specific users or groups.

Step 1: Set Up an ASP.NET Core SignalR Project

The SignalR server is part of the ASP.NET Core shared framework, so you do not need a NuGet package on the server side. Create a new web app:

// Terminal
// dotnet new web -n RealTimeDemo
// cd RealTimeDemo

Then register SignalR and map your hubs in Program.cs:

using RealTimeDemo.Hubs;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(options =>
{
    // Only show detailed errors to clients while developing
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.MaximumReceiveMessageSize = 64 * 1024; // default is 32 KB
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

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

app.MapHub<ChatHub>("/hubs/chat");

app.Run();

Why these options? ClientTimeoutInterval should be about double KeepAliveInterval. That way a single delayed ping does not disconnect a healthy client. Keep EnableDetailedErrors off in production, because exception messages can leak internal details to the browser.

Step 2: Build a Strongly Typed SignalR Hub (Chat Example)

A hub is the central class that clients connect to. You could call client methods with magic strings, like Clients.All.SendAsync("ReceiveMessage", ...). A strongly typed hub is better because the compiler catches typos in method names:

namespace RealTimeDemo.Hubs;

public interface IChatClient
{
    Task ReceiveMessage(string user, string message, DateTimeOffset sentAt);
    Task UserJoined(string user, string room);
    Task UserLeft(string user, string room);
}
using Microsoft.AspNetCore.SignalR;

namespace RealTimeDemo.Hubs;

public class ChatHub : Hub<IChatClient>
{
    private readonly ILogger<ChatHub> _logger;

    public ChatHub(ILogger<ChatHub> logger) => _logger = logger;

    private string CurrentUser => Context.User?.Identity?.Name ?? "Anonymous";

    public async Task JoinRoom(string room)
    {
        if (string.IsNullOrWhiteSpace(room) || room.Length > 50)
            throw new HubException("Invalid room name.");

        await Groups.AddToGroupAsync(Context.ConnectionId, room);
        await Clients.Group(room).UserJoined(CurrentUser, room);
    }

    public async Task LeaveRoom(string room)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
        await Clients.Group(room).UserLeft(CurrentUser, room);
    }

    public async Task SendToRoom(string room, string message)
    {
        if (string.IsNullOrWhiteSpace(message) || message.Length > 2000)
            throw new HubException("Message must be 1-2000 characters.");

        await Clients.Group(room).ReceiveMessage(CurrentUser, message, DateTimeOffset.UtcNow);
    }

    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("Connected: {ConnectionId}", Context.ConnectionId);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        _logger.LogInformation(exception, "Disconnected: {ConnectionId}", Context.ConnectionId);
        await base.OnDisconnectedAsync(exception);
    }
}

Why groups instead of tracking connections yourself?

Groups are SignalR's built-in way to broadcast to a subset of connections. They are cheap to create, and they are removed automatically when they become empty. When a connection drops, SignalR removes it from its groups for you. Note that group membership is not persisted. A client that reconnects gets a new connection ID and must join its rooms again. We handle this in the client code below.

Why throw HubException?

Ordinary exceptions reach the client as a generic "An unexpected error occurred" message (unless detailed errors are enabled). A HubException sends its message to the client every time. That makes it the right tool for validation errors the user should see.

Step 3: Connect the JavaScript Client

Install the client with npm install @microsoft/signalr, or load it from a CDN. Here is a minimal chat client in wwwroot/chat.js:

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

const currentRoom = "general";

connection.on("ReceiveMessage", (user, message, sentAt) => {
    const li = document.createElement("li");
    // textContent prevents XSS - never use innerHTML with user input
    li.textContent = `[${new Date(sentAt).toLocaleTimeString()}] ${user}: ${message}`;
    document.getElementById("messages").appendChild(li);
});

connection.on("UserJoined", (user, room) => console.log(`${user} joined ${room}`));

// Group membership is lost on reconnect, so rejoin
connection.onreconnected(async () => {
    await connection.invoke("JoinRoom", currentRoom);
});

async function start() {
    try {
        await connection.start();
        await connection.invoke("JoinRoom", currentRoom);
    } catch (err) {
        console.error(err);
        setTimeout(start, 5000);
    }
}

document.getElementById("send").addEventListener("click", async () => {
    const input = document.getElementById("message");
    await connection.invoke("SendToRoom", currentRoom, input.value);
    input.value = "";
});

start();

withAutomaticReconnect only handles connections that drop after a successful start. It does not retry the first connection attempt, which is why the start() function has its own retry loop.

Step 4: Real-Time Notifications with SignalR and IHubContext

Chat messages come from clients. Notifications usually come from your server code: an API controller, a domain service, or a background job. You cannot (and should not) create a hub instance yourself. Inject IHubContext<THub, TClient> instead.

First, define the notification hub and client contract. The [Authorize] attribute is important here, because notifications are per-user:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

namespace RealTimeDemo.Hubs;

public record NotificationDto(string Title, string Body, string Type, DateTimeOffset CreatedAt);

public interface INotificationClient
{
    Task ReceiveNotification(NotificationDto notification);
}

[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
    // No client-callable methods needed; the server pushes everything
}

Now push a notification from a regular service:

using Microsoft.AspNetCore.SignalR;
using RealTimeDemo.Hubs;

public class OrderService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hub;

    public OrderService(IHubContext<NotificationHub, INotificationClient> hub) => _hub = hub;

    public async Task ShipOrderAsync(int orderId, string customerUserId)
    {
        // ... update database, call shipping provider, etc.

        await _hub.Clients.User(customerUserId).ReceiveNotification(new NotificationDto(
            Title: "Order shipped",
            Body: $"Order #{orderId} is on its way!",
            Type: "success",
            CreatedAt: DateTimeOffset.UtcNow));
    }
}

How Clients.User() finds the right user

Clients.User(userId) sends to every connection that belongs to that user, so all of their browser tabs and devices receive it. By default SignalR identifies users by the ClaimTypes.NameIdentifier claim. If your identity provider uses a different claim (for example sub or email), implement IUserIdProvider:

using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;

public class SubClaimUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection) =>
        connection.User?.FindFirst("sub")?.Value
        ?? connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}

// Program.cs
builder.Services.AddSingleton<IUserIdProvider, SubClaimUserIdProvider>();

Sending notifications from a background service

IHubContext is a singleton, so you can use it from a BackgroundService as well. This is useful for scheduled alerts or queue consumers:

public class SystemStatusBroadcaster : BackgroundService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hub;

    public SystemStatusBroadcaster(IHubContext<NotificationHub, INotificationClient> hub) => _hub = hub;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            await _hub.Clients.All.ReceiveNotification(new NotificationDto(
                "System status", "All services operational", "info", DateTimeOffset.UtcNow));
        }
    }
}

// Program.cs
builder.Services.AddHostedService<SystemStatusBroadcaster>();

Step 5: Secure SignalR Hubs with JWT Authentication

Browsers cannot set custom headers on WebSocket or SSE requests. For those transports, the SignalR JavaScript client sends the token in the access_token query string instead. Your JWT bearer handler therefore has to read the token from the query string, and only for hub paths:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];

        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();

// After builder.Build():
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificationHub>("/hubs/notifications");

On the client, supply the token through accessTokenFactory. SignalR calls this factory on every connect and reconnect, so return a fresh token each time rather than a cached value that may have expired:

const notifications = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications", { accessTokenFactory: () => getAccessToken() })
    .withAutomaticReconnect()
    .build();

Security note: query strings can end up in server logs. Use HTTPS everywhere, keep token lifetimes short, and scrub access_token from request logging.

Step 6: A .NET Client for SignalR (Console, MAUI, WPF, Blazor)

SignalR is not browser-only. Add the Microsoft.AspNetCore.SignalR.Client NuGet package to connect from any .NET app:

using Microsoft.AspNetCore.SignalR.Client;

var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:5001/hubs/chat")
    .WithAutomaticReconnect()
    .Build();

connection.On<string, string, DateTimeOffset>("ReceiveMessage", (user, message, sentAt) =>
{
    Console.WriteLine($"[{sentAt:t}] {user}: {message}");
});

connection.Reconnected += async _ => await connection.InvokeAsync("JoinRoom", "general");

await connection.StartAsync();
await connection.InvokeAsync("JoinRoom", "general");

while (Console.ReadLine() is { Length: > 0 } line)
{
    await connection.InvokeAsync("SendToRoom", "general", line);
}

await connection.DisposeAsync();

Scaling SignalR: Redis Backplane and Azure SignalR Service

This is the part that often goes wrong in production. Each server only knows about its own connections. With two instances behind a load balancer, a message sent on server A never reaches a user connected to server B. There are two standard fixes:

Option 1: Redis backplane

// NuGet: Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
    .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options =>
    {
        options.Configuration.ChannelPrefix = RedisChannel.Literal("RealTimeDemo");
    });

Redis relays messages between servers using pub/sub. You still need sticky sessions (session affinity) on the load balancer. The one exception is when every client uses WebSockets only with skipNegotiation: true, because negotiation and long polling depend on reaching the same server.

Option 2: Azure SignalR Service

// NuGet: Microsoft.Azure.SignalR
builder.Services.AddSignalR().AddAzureSignalR(); // reads Azure:SignalR:ConnectionString

Azure SignalR Service takes over the client connections, so your app servers no longer hold thousands of open sockets and you do not need sticky sessions. For large-scale apps on Azure it is usually the simplest choice.

SignalR Best Practices

  • Use strongly typed hubs (Hub<T>) so the compiler catches misspelled client method names.
  • Keep hubs stateless. A new hub instance is created for every method call. Store state in a database, a cache, or a singleton service.
  • Validate every input. Hub methods are public endpoints. Treat them like API controllers.
  • Send small payloads. Push an ID or a small DTO and let the client fetch large data over HTTP if it needs to.
  • Rejoin groups after reconnecting, because group membership is tied to the connection ID.
  • Consider MessagePack (AddMessagePackProtocol()) for high-throughput scenarios. It produces smaller payloads and serializes faster than JSON.
  • Consider stateful reconnect (.NET 8+). Set AllowStatefulReconnects on MapHub and call withStatefulReconnect() on the client. This buffers messages during brief network blips.

Common SignalR Pitfalls (and How to Avoid Them)

1. Storing data in hub fields

A List<string> field on your hub will look empty on every call, because hubs are transient. Move shared state into a singleton service, and use a thread-safe collection such as ConcurrentDictionary.

2. Using the hub's Context outside the hub

IHubContext has no Context, no caller, and no connection ID. Outside a hub, target recipients with Clients.User(), Clients.Group(), or connection IDs that you stored yourself.

3. CORS errors from a separate front-end

If your React or Angular app runs on a different origin, you need a CORS policy with WithOrigins(...), AllowAnyHeader(), AllowAnyMethod(), and AllowCredentials(). You cannot combine AllowCredentials() with AllowAnyOrigin().

4. Messages silently dropped at scale

If notifications "sometimes" fail to arrive after you add a second server, you are missing a backplane or sticky sessions. Test with at least two instances locally before you go live.

5. Oversized messages

By default, the server rejects client messages larger than 32 KB and closes the connection. Raise MaximumReceiveMessageSize only as far as you need. For files, upload over HTTP rather than sending them through the hub.

6. Treating SignalR as guaranteed delivery

SignalR does not persist messages. A user who is offline when a notification is sent will never receive it. For important notifications, save them to a database first, push them in real time, and let the client load unread items when it connects.

Conclusion: Build Real-Time Apps with ASP.NET Core SignalR

ASP.NET Core SignalR gives C# developers a production-ready way to add real-time features without managing raw WebSockets. In this tutorial you built a multi-room chat application and a user-targeted notification system, secured them with JWT, and looked at how to scale them across servers.

Key takeaways:

  • Hubs are the core of SignalR. Make them strongly typed and stateless.
  • Use groups for rooms and channels, and Clients.User() for per-user notifications.
  • Inject IHubContext to push messages from controllers, services, and background jobs.
  • Read JWTs from the access_token query string on hub routes.
  • Scale out with a Redis backplane or Azure SignalR Service.
  • Persist critical notifications, because SignalR delivers messages in real time but does not store them.

Start with the chat hub above, add the notification hub, and you will have a working real-time foundation for your next ASP.NET Core project. Have a question or a SignalR tip of your own? Share it in the comments below.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...