Getting Started with ASP.NET Core SignalR: Hubs, Strongly Typed Hubs and Connecting from Angular with @microsoft/signalr
A SignalR hub is a transient handler created for every call; it holds no state and reports user-facing errors with HubException. AddSignalR and MapHub, type-safe calls with Hub<IClient>, transport negotiation, encapsulating the HubConnection in an Angular service that publishes signals, and AllowCredentials with an explicit origin list.
Version note: The code in this post was built and run with .NET 10, Angular 22.2 and @microsoft/signalr 10.0.11.
Guess First
You put a private int _calls field on a SignalR hub and increment it on every method call. The same client calls the method twice over the same connection. What is _calls on the second call?
Second question: inside a hub method you throw two different exceptions; one is HubException("Order 999 was not found."), the other InvalidOperationException("Carrier API is down"). In production, which of these messages does the client see?
Both answers tell you how SignalR is designed. In this post we build the live order board for Bookstore's warehouse screen: when a new order arrives the table updates itself, and when a worker presses the "Pack" button everybody else sees the change instantly.
Setting Up the Hub: AddSignalR and MapHub
using Bookstore.Realtime;
using Microsoft.AspNetCore.Http.Connections;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options => options.EnableDetailedErrors = builder.Environment.IsDevelopment());
builder.Services.AddSingleton<OrderBoardStore>();
builder.Services.AddScoped<IOrderQueries, OrderQueries>();
builder.Services.AddCors(options => options.AddPolicy("spa", policy => policy
.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowCredentials()));
WebApplication app = builder.Build();
app.UseCors("spa");
app.MapHub<OrderBoardHub>("/hubs/order-board", options =>
{
if (args.Contains("--no-websockets"))
{
// to mimic a proxy that blocks WebSockets
options.Transports = HttpTransportType.ServerSentEvents | HttpTransportType.LongPolling;
}
});
app.MapPost("/orders", async (NewOrder order, OrderBoardStore store, Microsoft.AspNetCore.SignalR.IHubContext<OrderBoardHub, IOrderBoardClient> hub) =>
{
OrderSummary created = store.Add(order.CustomerName, order.Total);
await hub.Clients.All.OrderUpdated(created);
return TypedResults.Created($"/orders/{created.Id}", created);
});
app.Run();
SignalR is part of ASP.NET Core; no separate NuGet package is needed. AddSignalR adds the hub infrastructure to the DI (Dependency Injection) container, and MapHub maps not one endpoint but a group of them: /hubs/order-board/negotiate and the address the transports use. In the options parameter of MapHub you configure transports, buffer sizes and the authentication settings we will see later.
MapPost("/orders") is an ordinary HTTP (Hypertext Transfer Protocol) endpoint, and it sends a message to every connected client from outside the hub through IHubContext. We look at who can send messages through the hub, and from where, in depth in the next post.
Strongly Typed Hubs: Hub<IOrderBoardClient>
public sealed record NewOrder(string CustomerName, decimal Total);
public sealed record OrderSummary(int Id, string CustomerName, decimal Total, string Status);
public interface IOrderBoardClient
{
Task OrderUpdated(OrderSummary order);
Task OrderRemoved(int orderId);
}
public sealed class OrderBoardHub(IOrderQueries queries, OrderBoardStore store, ILogger<OrderBoardHub> logger)
: Hub<IOrderBoardClient>
{
private int _calls; // a new hub instance per call: this counter is always 1
public override Task OnConnectedAsync()
{
string transport = Context.Features.Get<Microsoft.AspNetCore.Http.Connections.Features.IHttpTransportFeature>()?.TransportType.ToString() ?? "?";
logger.LogInformation("Connected {ConnectionId} via {Transport}", Context.ConnectionId, transport);
return base.OnConnectedAsync();
}
public Task<IReadOnlyList<OrderSummary>> GetOpenOrders()
{
_calls++;
logger.LogInformation("Hub instance {Hash}, calls on this instance: {Calls}", GetHashCode(), _calls);
return queries.GetOpenOrdersAsync(Context.ConnectionAborted);
}
public async Task MarkPacked(int orderId)
{
OrderSummary? updated = store.UpdateStatus(orderId, "Packed")
?? throw new HubException($"Order {orderId} was not found.");
await Clients.All.OrderUpdated(updated);
}
public Task Ship(int orderId) =>
throw new InvalidOperationException($"Carrier API is down for order {orderId}");
}
In a classic Hub you send a message to the client as Clients.All.SendAsync("OrderUpdated", order), writing the method name as a string. Make a typo and the compiler says nothing, the client receives nothing, and you see no error anywhere. With Hub<IOrderBoardClient>, Clients.All is now of type IOrderBoardClient: Clients.All.OrderUpdated(updated). The method name comes from the interface and the parameter types are checked at compile time. The methods on the interface must return Task, because "send" is asynchronous.
The client side has no such type safety: in TypeScript, connection.on('OrderUpdated', ...) is still a string. So it is a good habit to keep method names as constants in one place and change them together with the backend.
Hub Methods: Parameters, Return Types and Errors
A hub method's parameters and return value are serialised as JSON (JavaScript Object Notation) (System.Text.Json, camelCase). When a method returning Task<T> is called with invoke on the client, the promise resolves with that value; for a method returning Task, the client also waits for the method to finish. If you want "fire and forget" without waiting for an answer, the client has send. The test client I wrote with @microsoft/signalr on Node 24 connected, added an order with an HTTP POST, then called the methods. In the Development environment (EnableDetailedErrors = true):
[2026-11-08T02:25:55.219Z] Information: WebSocket connected to ws://localhost:17604/hubs/order-board?id=yDqAGuME_7ZiBPZDCI1F4Q.
[2026-11-08T02:25:55.222Z] Information: Using HubProtocol 'json'.
state: Connected id: jevGXZ6fTtGN0F3hUP8PKQ
OrderUpdated {"id":1001,"customerName":"Ada","total":42.5,"status":"Received"}
open orders: [{"id":1001,"customerName":"Ada","total":42.5,"status":"Received"}]
OrderUpdated {"id":1001,"customerName":"Ada","total":42.5,"status":"Packed"}
MarkPacked failed: An unexpected error occurred invoking 'MarkPacked' on the server. HubException: Order 999 was not found.
Ship failed: An unexpected error occurred invoking 'Ship' on the server. InvalidOperationException: Carrier API is down for order 1001
[2026-11-08T02:25:55.448Z] Information: Connection disconnected.
state: Disconnected
The same client in the Production environment:
MarkPacked failed: An unexpected error occurred invoking 'MarkPacked' on the server. HubException: Order 999 was not found.
Ship failed: An unexpected error occurred invoking 'Ship' on the server.
The answer to the second opening question: in production only the HubException message reaches the client. Other exceptions are written to the server log with a full stack trace, and the client gets a generic message. This is a deliberate security decision: the message of a database exception can leak table names or connection details. The rule is simple: every error you want to show the user is a HubException, the rest is for the logs. Note that we turn on EnableDetailedErrors only in Development.
The Hub's Lifetime: Transient
The test client called GetOpenOrders twice over the same connection. The server log:
info: Bookstore.Realtime.OrderBoardHub[0]
Connected jevGXZ6fTtGN0F3hUP8PKQ via WebSockets
info: Bookstore.Realtime.OrderBoardHub[0]
Hub instance 66502754, calls on this instance: 1
info: Bookstore.Realtime.OrderBoardHub[0]
Hub instance 49891972, calls on this instance: 1
The answer to the first opening question: 1. A new hub instance is created for every hub method call and disposed when the call ends. A hub is like a controller; it is not the connection itself but the handler of a single request coming over the connection. That is why keeping state in hub fields is always a mistake. If you need to store something that belongs to the connection, use Context.Items (a dictionary that lives for the duration of the connection) or a singleton store outside the hub; if you will run on more than one server, you need a shared place (Redis, a database).
A DI scope is also opened for every call. IOrderQueries is registered as scoped and comes in through the hub's constructor; in a real application this would be a service using an EF (Entity Framework) Core DbContext, and every hub call would get its own DbContext. If you push work to the background with Task.Run inside a hub method and return, the scope closes and the background code is left with a disposed DbContext. Long-running work goes to a background job, which sends the result through IHubContext.
Transport Negotiation
In the first line of the log above, the client connected with WebSocket. There is a negotiation behind that: the client first asks POST /hubs/order-board/negotiate which transports the server supports, then tries WebSocket, SSE (Server-Sent Events) and long polling in that order. To mimic a corporate proxy blocking WebSockets, I started the server with --no-websockets:
[2026-11-08T02:26:09.266Z] Information: SSE connected to http://localhost:17604/hubs/order-board?id=Yo3n-PWm7fTh9BjXiu0f5A
[2026-11-08T02:26:09.269Z] Information: Using HubProtocol 'json'.
state: Connected id: lpCHk_Vw62XdNj_Ru1m2bQ
info: Bookstore.Realtime.OrderBoardHub[0]
Connected lpCHk_Vw62XdNj_Ru1m2bQ via ServerSentEvents
Not a single line of application code changed; method calls and OrderUpdated messages worked exactly the same. Because SSE is one-way, every hub call from client to server goes out as a separate HTTP POST; latency and server load go up, but the application works. You can restrict the transport on the client too: withUrl(url, { transport: HttpTransportType.WebSockets, skipNegotiation: true }) opens a WebSocket directly without sending the negotiate request at all. That has an important consequence for scale-out, which we will see in the eighth post of the series.
A detail that is easy to miss in the log: the id=yDqAGuME... in the URL (Uniform Resource Locator) and the connectionId (jevGXZ6f...) differ. They come separately in the negotiate response; the connectionToken in the URL should only be known by the connection's owner, while the connectionId can be shared with other users. Knowing someone else's connectionId is not enough to hijack their connection.
Angular: Encapsulating the HubConnection in a Service
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
import { HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel } from '@microsoft/signalr';
export interface OrderSummary {
id: number;
customerName: string;
total: number;
status: string;
}
// provided at component level: the connection lives as long as the component
@Injectable()
export class OrderBoardService {
private readonly connection: HubConnection = new HubConnectionBuilder()
.withUrl('http://localhost:17604/hubs/order-board')
.configureLogging(LogLevel.Information)
.build();
private readonly ordersById = signal(new Map<number, OrderSummary>());
readonly orders = computed(() => [...this.ordersById().values()]);
readonly state = signal<HubConnectionState>(HubConnectionState.Disconnected);
constructor() {
this.connection.on('OrderUpdated', (order: OrderSummary) =>
this.ordersById.update((map) => new Map(map).set(order.id, order)),
);
this.connection.on('OrderRemoved', (orderId: number) =>
this.ordersById.update((map) => {
const next = new Map(map);
next.delete(orderId);
return next;
}),
);
this.connection.onclose(() => this.state.set(this.connection.state));
inject(DestroyRef).onDestroy(() => {
this.connection.off('OrderUpdated');
this.connection.off('OrderRemoved');
void this.connection.stop();
});
}
async start(): Promise<void> {
if (this.connection.state !== HubConnectionState.Disconnected) {
return;
}
this.state.set(HubConnectionState.Connecting);
await this.connection.start();
this.state.set(this.connection.state);
// fetch the current list after connecting; push messages arrive on top of it
const open = await this.connection.invoke<OrderSummary[]>('GetOpenOrders');
this.ordersById.set(new Map(open.map((order) => [order.id, order])));
}
markPacked(orderId: number): Promise<void> {
return this.connection.invoke('MarkPacked', orderId);
}
}
There are a few design decisions here. The HubConnection is inside the service, and only signals and meaningful methods (markPacked) are exposed; the component knows nothing about SignalR. The connection state is a signal too, so the template can show it directly. @Injectable() has no providedIn; we provide the service in the component's providers array, so the DestroyRef is the component's, and when the page closes the handlers are removed and the connection is stopped. If you want a single connection for the whole application, use providedIn: 'root' and do not leave stop() to a component.
Then there is the order: we connect first and then fetch the list with GetOpenOrders. Do it the other way round (fetch the list over HTTP first, then connect) and an order arriving in the few hundred milliseconds in between never shows up on screen.
import { Component, OnInit, inject, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { OrderBoardService } from './order-board.service';
@Component({
selector: 'app-order-board',
imports: [CurrencyPipe],
providers: [OrderBoardService],
template: `
<p>Connection: {{ board.state() }}</p>
@if (error(); as message) {
<p class="error">{{ message }}</p>
}
<table>
@for (order of board.orders(); track order.id) {
<tr>
<td>#{{ order.id }}</td>
<td>{{ order.customerName }}</td>
<td>{{ order.total | currency: 'USD' }}</td>
<td>{{ order.status }}</td>
<td><button (click)="pack(order.id)" [disabled]="order.status !== 'Received'">Pack</button></td>
</tr>
}
</table>
`,
})
export class OrderBoardComponent implements OnInit {
protected readonly board = inject(OrderBoardService);
protected readonly error = signal<string | null>(null);
async ngOnInit(): Promise<void> {
try {
await this.board.start();
} catch (e) {
this.error.set(`Could not connect: ${(e as Error).message}`);
}
}
async pack(orderId: number): Promise<void> {
try {
await this.board.markPacked(orderId);
} catch (e) {
this.error.set((e as Error).message);
}
}
}
I tried it in the browser: before opening the page I added orders for Ada and Grace, while the page was open I added Alan's with curl, then pressed the "Pack" button on Grace's row. The final state of the page:
Connection: Connected
#1001 Ada $42.50 Received Pack
#1002 Grace $42.50 Packed Pack
#1003 Alan $19.90 Received Pack
The first two rows came from GetOpenOrders, the third from the OrderUpdated push, and Grace's "Packed" status from the result of my own click, broadcast by the server to every client. In Angular 22 we run zoneless; even though SignalR callbacks run without Zone.js knowing about them, the signal update refreshes the screen.
CORS: AllowCredentials and an Explicit Origin List
When the Angular app is on 4200 and the API (Application Programming Interface) on another port or domain, the browser performs a CORS (Cross-Origin Resource Sharing) check for the negotiate request. SignalR's browser client sends requests with withCredentials (for cookies and sticky session cookies), so AllowCredentials() is required in the policy. If you add AllowAnyOrigin() to "just allow everything", the application does not even start:
System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
That is a good error; allowing every origin with credentials means any site could connect to your hub with the user's session. The fix is an explicit origin list (WithOrigins). Silencing the error with SetIsOriginAllowed(_ => true) brings back the same security hole. The details of CORS are in the CORS post of the ASP.NET Core Web API series.
Production Notes
Hub methods should be short. Hub calls from the same connection are processed one at a time by default (MaximumParallelInvocationsPerClient defaults to 1); if a method takes 10 seconds, that client's next calls wait 10 seconds too. Put long work on a queue and push the result.
Know the message size. The largest incoming message the server accepts is 32 KB by default (MaximumReceiveMessageSize). If you need to send a large payload from the client, use an HTTP upload instead of SignalR, or look at client-to-server streaming in the seventh post of the series.
Know the Microsoft.AspNetCore.SignalR and Microsoft.AspNetCore.Http.Connections categories in your logs. The first writes hub calls, the second transport and connection events; when investigating connection problems, temporarily raising both to Debug is the first step.
Common Pitfalls
Keeping state in hub fields. A new instance per call; fields do not survive between calls.
Storing the hub somewhere static and sending messages from there. Using Clients after the hub is disposed fails; IHubContext exists for sending from outside the hub.
Writing the method name as a string in a classic Hub and making a typo. The client receives nothing and you see no error anywhere; use a strongly typed hub.
Throwing an error you want to show the user as an ordinary exception. In production the client only sees "An unexpected error occurred"; use HubException.
Fetching data first and connecting afterwards. Messages arriving in the gap are lost; connect first, then fetch.
Using AllowAnyOrigin in CORS, or a predicate that accepts every origin.
A Small Experiment
★☆☆ Turn OrderBoardHub into a classic Hub and write SendAsync("OrderUpdate", ...) with one letter missing. Note what you see in the browser and in the server log.
★★☆ Start the server with --no-websockets, open the Angular app, and look in the browser's network tab at how hub calls go out as separate POST requests. Then limit the transport to LongPolling only and look again.
★★★ Add await Task.Delay(5000) to MarkPacked and pack two different orders one after the other from the same tab. Explain why the second call waits 5 seconds, then set MaximumParallelInvocationsPerClient to 2 and try again.
In the next post we focus on who receives a message: Clients.User, Clients.Group, the connection lifecycle, a user's multiple tabs, presence, and sending notifications from a controller, a background job and a message consumer with IHubContext.