Articles › ASP.NET Core
Real-Time Apps with ASP.NET Core SignalR: Hubs, Groups and a React Client
Add real-time features with SignalR: hubs, sending to users and groups, strongly typed hubs, authentication, a React client with reconnection, scaling with Redis and common pitfalls.
Some features can't wait for the user to refresh: live notifications, chat, dashboards updating every second, the status of a long-running job, a map showing vehicle positions. SignalR is ASP.NET Core's library for real-time, two-way communication between server and clients. It uses WebSockets when available and falls back to other transports automatically.
How SignalR works
- The client opens a persistent connection to a hub endpoint.
- The server can call client methods at any time (push), and clients can call server methods.
- SignalR negotiates the best transport: WebSockets, then Server-Sent Events, then long polling.
- Messages are serialized as JSON by default (MessagePack is available for smaller payloads).
1. Create a hub
public class NotificationHub : Hub
{
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, "everyone");
await base.OnConnectedAsync();
}
// Clients can call this
public async Task JoinProject(int projectId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"project-{projectId}");
}
public Task SendMessage(string text) =>
Clients.Others.SendAsync("messageReceived", Context.User?.Identity?.Name, text);
}
Register it:
builder.Services.AddSignalR();
app.MapHub<NotificationHub>("/hubs/notifications");
2. Strongly typed hubs
String method names are typo-prone. Define a client interface instead:
public interface INotificationClient
{
Task NotificationReceived(NotificationDto notification);
Task JobProgress(Guid jobId, int percent);
}
public class NotificationHub : Hub<INotificationClient>
{
public Task Ping() => Clients.Caller.NotificationReceived(new NotificationDto("pong"));
}
Now the compiler checks every call to the client.
3. Push from anywhere with IHubContext
Most real-time events originate outside the hub — in a controller, service or background worker. Inject IHubContext:
public class OrderService(AppDbContext db, IHubContext<NotificationHub, INotificationClient> hub)
{
public async Task MarkShippedAsync(int orderId, CancellationToken ct)
{
var order = await db.Orders.FindAsync([orderId], ct);
order!.Status = OrderStatus.Shipped;
await db.SaveChangesAsync(ct);
await hub.Clients.User(order.CustomerId.ToString())
.NotificationReceived(new NotificationDto($"Order #{order.Id} has shipped 🚚"));
}
}
Targets you can send to:
| Target | Who receives |
|---|---|
Clients.All |
every connection |
Clients.Caller / Others |
the calling connection / everyone else |
Clients.User(userId) |
all connections of one user (all tabs and devices) |
Clients.Group(name) |
members of a group (a chat room, a project, a tenant) |
Clients.Client(connectionId) |
one specific connection |
4. Authentication
Hubs support [Authorize] like controllers:
[Authorize]
public class NotificationHub : Hub<INotificationClient> { }
Browsers can't set custom headers on WebSocket connections, so with JWT the token is sent as a query string parameter. Read it in the JwtBearer events:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
context.Token = token;
return Task.CompletedTask;
}
};
Clients.User(id) uses the NameIdentifier claim by default; customize it with an IUserIdProvider if your user id lives in another claim (e.g. sub).
5. A React client
npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";
export function useNotifications(token) {
const [items, setItems] = useState([]);
useEffect(() => {
if (!token) return;
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", { accessTokenFactory: () => token })
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Warning)
.build();
connection.on("notificationReceived", (n) => setItems((prev) => [n, ...prev].slice(0, 50)));
connection.onreconnected(() => connection.invoke("JoinProject", 42));
connection.start()
.then(() => connection.invoke("JoinProject", 42))
.catch((err) => console.error("SignalR connection failed", err));
return () => {
connection.stop();
};
}, [token]);
return items;
}
Key points:
- Register handlers (
on) beforestart(), so no message is missed. withAutomaticReconnecthandles network drops — but group membership is lost on reconnect, so re-join inonreconnected.- Stop the connection in the cleanup function to avoid duplicate connections (especially in StrictMode).
6. Reporting progress of long jobs
A classic pattern: an endpoint starts a job and returns 202 Accepted immediately; a background worker processes it and pushes progress:
for (var i = 1; i <= total; i++)
{
await ProcessAsync(items[i - 1], ct);
await hub.Clients.User(userId).JobProgress(jobId, i * 100 / total);
}
The UI shows a live progress bar instead of a spinner that may time out.
7. Scaling out
Each server keeps its own connections in memory. With several instances behind a load balancer, a message sent on server A won't reach clients connected to server B. Solutions:
- Redis backplane —
builder.Services.AddSignalR().AddStackExchangeRedis(connectionString); - Azure SignalR Service — offloads all connections to a managed service.
- Sticky sessions are required on the load balancer unless you use WebSockets only (
skipNegotiation) or Azure SignalR.
On IIS, enable the WebSocket Protocol Windows feature, otherwise SignalR silently falls back to slower transports.
Common pitfalls
- Sending huge payloads — send IDs or small DTOs; let clients fetch details via the API if needed.
- Storing state in hub instances — hubs are transient; each call creates a new instance. Use a store, cache or database.
- Ignoring reconnection — re-join groups and reload missed data after reconnecting.
- Using SignalR for everything — standard request/response belongs in REST; use SignalR for genuinely live updates.
- Trusting client calls — validate and authorize hub method inputs just like controller actions.
Summary
SignalR gives ASP.NET Core real-time, bidirectional communication with automatic transport negotiation. Define (strongly typed) hubs, push from any service through IHubContext, target users and groups, secure hubs with [Authorize] and a query-string token, connect from React with automatic reconnection, and scale out with Redis or Azure SignalR. Use it for notifications, live dashboards, chat and job progress — and keep everything else in your REST API.
FAQ
Is SignalR only for chat apps?
No. Notifications, live dashboards, collaborative editing, IoT and job progress are all common uses.
Do I need WebSockets enabled on the server?
SignalR works without them via fallbacks, but WebSockets are much more efficient. Enable them on IIS and reverse proxies.
How many connections can one server handle?
Thousands to tens of thousands, depending on message volume and hardware. Beyond that, use a backplane or Azure SignalR Service.