Building Real-time application with .NET SignalR and Angular

Introduction
Polling an API every two seconds to see if anything changed is the workaround everyone reaches for first, and it is wasteful on both ends - most requests return "nothing new", and the user still sees updates a second or two late. Real-time features (live dashboards, chat, collaborative editing, notifications) need the server to push.
SignalR is ASP.NET Core's abstraction over that push. It negotiates the best available transport (WebSockets when possible, with fallbacks), handles reconnection, and lets you call methods on connected clients from the server as if they were local. In this build you create a shared shopping list: multiple people open the same list, and when anyone adds or removes an item, everyone else sees it instantly. The backend is a single SignalR hub; the frontend is Angular using the @microsoft/signalr client.
🎬 Watch the full video here:
What a hub actually is
A hub is a class that inherits from Hub and exposes public methods the client can call. In return, the hub can call methods on clients through Clients.Caller, Clients.All, Clients.Group(name), and so on. It is a two-way RPC channel over a persistent connection.
public class ShoppingListHub : Hub
{
public async Task AddItem(string shoppingListId, string itemName) { /* ... */ }
public async Task RemoveItem(string shoppingListId, string itemName) { /* ... */ }
public async Task JoinShoppingList(string shoppingListId) { /* ... */ }
public async Task CreateShoppingList() { /* ... */ }
}
Registration is two lines - builder.Services.AddSignalR() and app.MapHub<ShoppingListHub>("/shoppingListHub"). The path is the URL the Angular client connects to.
Groups are how you scope a broadcast
The demo does not broadcast every change to every connected user - it would make no sense for someone editing list A to receive events for list B. SignalR groups solve this. A group is just a named bucket of connections:
await Groups.AddToGroupAsync(Context.ConnectionId, shoppingListId);
Once a connection is in the group named after a shopping list ID, the hub targets that list specifically:
await Clients.Group(shoppingListId).SendAsync("ReceiveShoppingList", list);
"ReceiveShoppingList" is a client-side method name and a loose contract - the Angular client registers a handler under that exact string. Context.ConnectionId identifies the current connection and is the unit you add to and remove from groups.
Client-to-server and server-to-client are different directions
It helps to keep the two flows straight:
- Client calls the hub: the Angular service invokes
AddItem,RemoveItem,JoinShoppingList, orCreateShoppingListby name, passing arguments. These map to the public methods on the hub. - Hub calls the clients: the hub invokes
SendAsync("ReceiveShoppingList", ...),SendAsync("ShoppingListCreated", ...),SendAsync("JoinShoppingList", ...). The Angular client has a handler registered for each of those names.
When someone adds an item, the round trip is: client invokes AddItem on the hub, the hub mutates the list and calls ReceiveShoppingList on the whole group, and every client in that group (including the sender) re-renders from the list it received. Treating the server broadcast as the single source of truth - rather than optimistically updating the sender's UI separately - keeps everyone consistent.
The demo's in-memory store, and its limits
The hub keeps lists in a static ConcurrentDictionary<string, List<string>>. That is a fine teaching choice - it keeps the focus on SignalR - but be clear about what it costs:
- State is lost on restart.
- It does not work across multiple server instances, because each instance has its own dictionary and its own set of group memberships.
For production, list state belongs in a database, and if you scale past one server you need a backplane (the Redis backplane is the standard choice) so a broadcast on one instance reaches connections held by another. The hub code barely changes; the infrastructure around it does.
CORS is the part that bites everyone
An Angular dev server on http://localhost:4200 connecting to an API on a different port is a cross-origin request, and a SignalR connection has an extra requirement most REST calls do not: credentials.
builder.Services.AddCors(o =>
{
o.AddPolicy("MyPolicy", p => p
.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowCredentials());
});
Two things matter here. AllowCredentials() is required for the SignalR handshake, and it is incompatible with AllowAnyOrigin() - you must name the origin explicitly with WithOrigins. And app.UseCors("MyPolicy") has to run before app.MapHub(...) in the pipeline. Get either wrong and the browser console shows a CORS failure on the negotiate request with no useful server-side error.
Common pitfalls
- Method name typos. The link between
SendAsync("ReceiveShoppingList", ...)and the client handler is a magic string. A mismatch fails silently - no error, just nothing happens. - Forgetting to join the group. If a client never calls
JoinShoppingList, it is connected but receives no group broadcasts. AllowAnyOrigin()with credentials. The combination is rejected; you will not get a WebSocket connection.- Assuming the in-memory store scales. The moment you run two instances, half your users stop seeing updates.
- Not handling reconnection on the client. The
@microsoft/signalrclient can reconnect automatically, but group membership is per connection - after a reconnect the client must re-join its group.
Key Takeaways
- SignalR is a two-way RPC channel over a persistent connection: the client calls hub methods by name, the hub calls client handlers by name.
- Groups scope broadcasts. Add
Context.ConnectionIdto a named group, then targetClients.Group(name). - Treat the server broadcast as the single source of truth so every client re-renders from the same data.
- The in-memory dictionary is a teaching shortcut - real apps persist state and, past one server instance, need a Redis backplane.
- CORS must use
WithOriginsplusAllowCredentials(neverAllowAnyOriginwith credentials), andUseCorsmust come beforeMapHub. - The client must re-join its groups after an automatic reconnect.
Get the Full Source Code
The complete runnable solution - the ASP.NET Core hub, the group and event wiring, and the Angular client with the @microsoft/signalr service and components - is available to Patreon supporters. If you want to open two browser tabs and watch a list sync live instead of rebuilding it from the walkthrough above, you can find it on Patreon.