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
AllowStatefulReconnectsonMapHuband callwithStatefulReconnect()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
IHubContextto push messages from controllers, services, and background jobs. - Read JWTs from the
access_tokenquery 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.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment