Real-Time Communication with SignalR
Reviewed & published by Brayan K
By the end of this lesson you'll understand how SignalR lets a server push updates to clients the instant they happen — powering live chat, dashboards, and notifications without the client constantly asking. You'll read real hub code, then drill the underlying publish/subscribe idea in plain C# you can run right here.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Explain why server push (SignalR) beats client polling for live data
- Write a Hub and call its methods from a JavaScript client
- Target messages with Clients.All, Clients.Caller, and Clients.Group
- Use groups as private channels and add/remove connections at runtime
- Handle the connection lifecycle (connect, disconnect, reconnect)
- Understand the WebSocket / SSE / long-polling transport fallback
📡 Real-World Analogy
Think of SignalR as a radio station. Once a listener tunes in (connects), the station can broadcast to everyone at once — that's Clients.All. The station doesn't wait for listeners to phone in and ask "any news yet?" every few seconds; it simply pushes the moment something happens. Groups are like private channels on the same station — a sports channel and a music channel — where only the people tuned to that channel hear the broadcast. And just as a radio falls back from FM to AM where the signal is weak, SignalR automatically falls back from WebSockets to other transports so the connection keeps working.
Push vs Polling — the core idea
Plain HTTP is request/response: the client asks, the server answers, the connection closes. To show live data that way, the client has to poll — ask "anything new?" over and over on a timer. Polling is wasteful (most answers are "no") and laggy (you only see updates as fast as you ask).
SignalR keeps a single connection open so the server can push a message the instant something changes — no repeated asking. That's the whole reason it exists, and it's the right tool whenever many clients need the same live updates: chat, notifications, live scores, collaborative editing, trading dashboards.
- 🔁 Polling — client asks every few seconds; lots of empty requests; updates lag.
- 📡 SignalR push — one open connection; server sends only when there's news; near-instant.
SignalR runs on an ASP.NET Core server, so the hub examples below won't execute in the in-page fiddle. Read them carefully, then practise the same publish/subscribe pattern in the runnable plain-C# exercises — the mental model transfers directly.
📊 Targeting Clients — who receives a message
| Target | Who gets it | Typical use |
|---|---|---|
| Clients.All | Every connected client | Global broadcast, announcements |
| Clients.Caller | Only the client that called | Acknowledgements, replies |
| Clients.Others | Everyone except the caller | "User joined / is typing" |
| Clients.Group(name) | Members of that group | Chat rooms, channels |
| Clients.Client(id) | One connection by id | Direct/private message |
| Clients.User(userId) | All of one user's connections | Notify a person on every device |
The first argument to SendAsync("Name", ...) is the method name the client is listening for — it's a plain string, so a typo silently drops the message. (Strongly-typed hubs replace these strings with interface methods.)
1. The Hub — Where Clients Connect
A Hub is the server-side class clients connect to. Every public method on it is something a client can invoke over the wire, and from inside the hub you push back out with Clients.<target>.SendAsync("HandlerName", args...). The string "HandlerName" must match a handler the client registered, or the message just vanishes. Read this worked example — it shows the radio broadcast (Clients.All) and a private reply (Clients.Caller).
using Microsoft.AspNetCore.SignalR;
// ChatHub.cs — a Hub is the server class clients connect to.
// Each PUBLIC method here is something a client can call over the wire.
// NOTE: SignalR needs an ASP.NET Core host, so this won't run in the fiddle —
// study it, then practise the same PUSH idea in plain C# further down.
public class ChatHub : Hub
{
// A client calls this; the server then PUSHES to every connected client.
public async Task SendMessage(string user, string message)
{
// Clients.All = the radio broadcast — everyone tuned in hears it.
// "ReceiveMessage" is the method name the CLIENT must be listening for.
await Clients.All.SendAsync("ReceiveMessage", user, message);
// ✅ Expected: every browser connected to this hub fires its
// "ReceiveMessage" handler with (user, message).
}
// Clients.Caller = reply only to the client that made this call.
public async Task Echo(string message)
{
await Clients.Caller.SendAsync("ReceiveMessage", "echo", message);
// ✅ Expected: only the sender sees this one — nobody else.
}
}SignalR needs a web host, so it won't run in the fiddle — but its broadcasting is really just publish/subscribe: a list of listeners, and a loop that hands each one the message. You can build exactly that in plain C# and run it. Fill in the two ___ blanks:
using System;
using System.Collections.Generic;
// 🎯 YOUR TURN — SignalR can't run here, so we model the SAME idea in
// plain C#. A "hub" keeps a list of subscriber handlers and broadcasts
// a message to every one of them — exactly like Clients.All.SendAsync.
class MiniHub
{
// Every subscriber is an Action<string> — a handler that takes a message.
private readonly List<Action<string>> _subscribers = new();
// Subscribe = the client "tunes in".
public void Subscribe(Action<string> handler)
{
_subscribers.Add(handler);
}
// Broadcast to ALL subscribers (the Clients.All idea).
public void Broadcast(string message)
{
foreach (var handler in _subscribers)
handler(___); // 👉 pass the message to each handler
}
}
class Program
{
static void Main()
{
var hub = new MiniHub();
// 1) Subscribe TWO handlers (two "clients" listening).
hub.Subscribe(msg => Console.WriteLine($"Client A got: {msg}"));
hub.___(msg => Console.WriteLine($"Client B got: {msg}")); // 👉 Subscribe
// 2) Broadcast one message to everyone tuned in.
hub.Broadcast("hi");
// ✅ Expected output:
// Client A got: hi
// Client B got: hi
}
}2. Groups — Private Channels
Groups are named sets of connections — the private channels on the radio station. You add the current connection with Groups.AddToGroupAsync(Context.ConnectionId, room) and broadcast to just that channel with Clients.Group(room).SendAsync(...). A connection can be in many groups at once, and groups are created and destroyed automatically as connections join and leave. This is how chat rooms, document sessions, and per-topic feeds are built.
using Microsoft.AspNetCore.SignalR;
// Groups are PRIVATE channels — only members of a group receive a
// Group broadcast. Think of a conference call inside the radio station.
public class RoomHub : Hub
{
// Add THIS connection to a named group (e.g. a chat room).
public async Task JoinRoom(string room)
{
await Groups.AddToGroupAsync(Context.ConnectionId, room);
// Tell only this room that someone arrived.
await Clients.Group(room).SendAsync("ReceiveMessage",
"System", $"A user joined {room}");
// ✅ Expected: only clients already in 'room' get the "joined" notice.
}
// Send a message to ONE group only — outsiders never see it.
public async Task SendToRoom(string room, string user, string message)
{
await Clients.Group(room).SendAsync("ReceiveMessage", user, message);
// ✅ Expected: members of 'room' fire ReceiveMessage; everyone else
// on the hub hears nothing.
}
public async Task LeaveRoom(string room)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
// ✅ Expected: this connection stops receiving 'room' broadcasts.
}
}
// Program.cs — register SignalR and map the hub to a URL.
// var builder = WebApplication.CreateBuilder(args);
// builder.Services.AddSignalR();
// var app = builder.Build();
// app.MapHub<RoomHub>("/hubs/room");
// app.Run();Now model groups yourself: a Dictionary mapping each group name to its list of subscribers, so a send reaches only that group. Fill in the two ___ blanks, then run it and confirm the music fan hears nothing:
using System;
using System.Collections.Generic;
// 🎯 YOUR TURN — add GROUPS to the hub: a Dictionary mapping a
// group name to the list of subscribers in it. Sending to a group
// reaches only that group (the Clients.Group idea).
class GroupHub
{
// group name -> the handlers subscribed to that group
private readonly Dictionary<string, List<Action<string>>> _groups = new();
// Add a handler to a named group (creating the group if it's new).
public void JoinGroup(string group, Action<string> handler)
{
if (!_groups.ContainsKey(group))
_groups[group] = new List<Action<string>>();
_groups[group].Add(handler);
}
// Send to ONE group only.
public void SendToGroup(string group, string message)
{
if (!_groups.ContainsKey(group)) return; // unknown group: nothing to do
foreach (var handler in _groups[___]) // 👉 use the group parameter
handler(message);
}
}
class Program
{
static void Main()
{
var hub = new GroupHub();
// Two clients join "sports", one joins "music".
hub.JoinGroup("sports", msg => Console.WriteLine($"Sports fan 1: {msg}"));
hub.JoinGroup("sports", msg => Console.WriteLine($"Sports fan 2: {msg}"));
hub.JoinGroup("music", msg => Console.WriteLine($"Music fan: {msg}"));
// 👉 Send "Goal!" to the "sports" group only.
hub.SendToGroup(___, "Goal!"); // 👉 first argument is the group name
// ✅ Expected output (the music fan hears NOTHING):
// Sports fan 1: Goal!
// Sports fan 2: Goal!
}
}3. The JavaScript Client
A hub is only half the story — something has to connect to it. The browser uses the @microsoft/signalr package: build a connection to the hub URL, register connection.on("HandlerName", ...) handlers, then start(). Register your handlers before start() so you don't miss messages that arrive during connection, and use withAutomaticReconnect() so a dropped link silently reconnects.
// JavaScript client — the browser end of the connection.
// Install: npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat") // must match app.MapHub(...)
.withAutomaticReconnect() // auto-retry if the link drops
.build();
// Register handlers BEFORE start() — the name must match the server's
// SendAsync("ReceiveMessage", ...) exactly, or the message is dropped.
connection.on("ReceiveMessage", (user, message) => {
console.log(user + ": " + message);
// ✅ Expected: fires every time the server PUSHES a ReceiveMessage.
});
async function start() {
await connection.start(); // opens the persistent connection
console.log("Connected!"); // ✅ Expected: "Connected!"
// Call a server hub method by name:
await connection.invoke("SendMessage", "Alice", "Hi everyone");
}
start();4. The Connection Lifecycle
SignalR creates a new hub instance for every call and discards it, so you must never store per-user state in hub fields — it won't survive. Instead, hook the lifecycle: override OnConnectedAsync when a client joins and OnDisconnectedAsync when it leaves (whether it closed cleanly, crashed, or timed out). Each connection gets a fresh Context.ConnectionId that is valid only for that one connection — it changes on every reconnect, so never persist it as a user identifier.
using Microsoft.AspNetCore.SignalR;
// A Hub instance is created PER CALL and thrown away — never store state
// in fields. Track connections with the lifecycle overrides instead.
public class PresenceHub : Hub
{
// Runs when a client opens the connection.
public override async Task OnConnectedAsync()
{
// Context.ConnectionId is a fresh id for THIS connection only.
await Clients.Others.SendAsync("UserJoined", Context.ConnectionId);
await base.OnConnectedAsync();
// ✅ Expected: everyone except the newcomer hears "UserJoined".
}
// Runs when the client closes, drops, or times out.
public override async Task OnDisconnectedAsync(Exception? error)
{
await Clients.Others.SendAsync("UserLeft", Context.ConnectionId);
await base.OnDisconnectedAsync(error);
// ✅ Expected: the ConnectionId is now gone — never reuse it.
}
}🔎 Deep Dive: transports & the automatic fallback
SignalR is a layer over several transports — the actual technique used to keep data flowing. When a client connects, SignalR negotiates the best one both sides support and silently falls back if it can't be used (a proxy blocking WebSockets, say). Your hub code is identical regardless of which transport wins.
- 🥇 WebSockets — a true two-way persistent connection. Fastest and lowest overhead; the preferred choice.
- 🥈 Server-Sent Events (SSE) — server-to-client streaming only; client-to-server uses separate requests. Used when WebSockets aren't available.
- 🥉 Long Polling — the client holds an HTTP request open until the server has something, then immediately reopens it. The universal fallback that works almost everywhere.
Because the fallback is automatic, "use SignalR" rather than hand-rolling raw WebSockets gives you reconnection, transport negotiation, and a clean API for free.
Pro Tips
- 💡 Keep hub methods fast and non-blocking: a hub processes messages on a connection one at a time, so a slow method stalls that client. Offload heavy work to a background service and await real I/O — never block.
- 💡 Use a Redis (or Azure SignalR) backplane when you run more than one server: builder.Services.AddSignalR().AddStackExchangeRedis("…"); so a broadcast from one server reaches clients connected to the others.
- 💡 Push from outside the hub with IHubContext<THub>: inject it into a controller or background job to send messages when there's no client call to react to.
- 💡 Prefer strongly-typed hubs (Hub<IClient>) to replace magic strings like "ReceiveMessage" with compiler-checked interface methods.
- 💡 Send small payloads: broadcast an id or a delta, not a megabyte of JSON to thousands of clients at once.
Common Errors (and the fix)
- No authentication on the hub: a hub method is a public endpoint — anyone who can reach the URL can call it. Protect it with [Authorize] on the hub (or method) and read the user from Context.User; never trust a user id passed in as an argument.
- Blocking inside a hub method: calling Thread.Sleep(...) or .Result/.Wait() on a task freezes that connection's message processing and can starve the thread pool. Use await Task.Delay(...) and async I/O instead.
- Assuming a single server / sticky sessions: behind a load balancer, two clients may land on different servers, so Clients.All only reaches one server's clients. Add a backplane (Redis or Azure SignalR) so broadcasts cross all instances.
- Huge broadcasts: sending large messages to Clients.All with thousands of connections floods the network and memory. Send small payloads, throttle the rate, and target groups instead of everyone where you can.
- Client handler name doesn't match: SendAsync("ReceiveMsg", …) on the server but connection.on("ReceiveMessage", …) on the client — the names differ, so the message is silently dropped. Keep them identical (typed hubs prevent this).
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Define a hub | class ChatHub : Hub { ... } | Server class clients connect to |
| Broadcast to all | Clients.All.SendAsync("M", x) | Every connected client |
| Reply to caller | Clients.Caller.SendAsync(...) | Only the sender |
| Join a group | Groups.AddToGroupAsync(id, "room") | id = Context.ConnectionId |
| Send to a group | Clients.Group("room").SendAsync(...) | Members only |
| Map the hub | app.MapHub<ChatHub>("/hubs/chat") | In Program.cs |
| JS: listen | connection.on("M", fn) | Before start() |
| JS: call server | connection.invoke("SendMessage", …) | Calls a hub method |
Frequently Asked Questions
Q: Why use SignalR instead of just polling the server?
Polling means every client repeatedly asks "anything new?", which is wasteful and laggy. SignalR keeps one connection open so the server pushes the instant there's news — fewer requests, near-instant updates. For live data with many clients, push wins.
Q: Is SignalR the same as raw WebSockets?
No — WebSockets is one of the transports SignalR can use. SignalR adds automatic transport negotiation and fallback (to SSE or long polling), reconnection, groups, and a clean hub/client API on top, so you don't have to build all that yourself.
Q: Can I send a message to clients from outside a hub?
Yes. Inject IHubContext<YourHub> into a controller or background service and call _hub.Clients.All.SendAsync(...). That's how a finished order or a scheduled job can push a notification with no client call to react to.
Q: Why shouldn't I store the ConnectionId to identify a user?
A ConnectionId is per-connection and changes on every reconnect, and one user may have several (phone, laptop). Identify users by their authenticated user id and let SignalR map it to connections with Clients.User(userId).
Q: Do I need anything special to run SignalR across multiple servers?
Yes — a backplane (Redis or Azure SignalR Service). Without it, a broadcast only reaches clients on the same server instance that sent it, because each server only knows its own connections.
Mini-Challenge: a Chat Room
No blanks this time — just a brief and an outline. Build a ChatRoom that keeps a list of member handlers, lets members Join, and Broadcasts a message to every member so each prints its own receipt. It's the SignalR push model in miniature: one publisher, many subscribers. Run it and check the two receipts against the expected output.
using System;
using System.Collections.Generic;
// 🎯 MINI-CHALLENGE: a tiny chat-room model (the SignalR push idea in plain C#)
// 1. Make a ChatRoom class with a private List<Action<string>> called members.
// 2. Add Join(Action<string> onMessage): add the handler to members.
// 3. Add Broadcast(string from, string text): loop over members and call
// each handler with a line like $"{from}: {text}".
// 4. In Main: create a room, Join TWO members that print what they receive,
// then Broadcast one message from "Alice".
//
// ✅ Expected output (two receipts — one per member):
// [Member 1] Alice: hello room
// [Member 2] Alice: hello room
class ChatRoom
{
// your private members list, Join, and Broadcast here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ SignalR lets the server push updates over one open connection — no polling
- ✅ A Hub is the server class; its public methods are what clients invoke
- ✅ Target messages with Clients.All, Caller, Others, Group, and Client
- ✅ Groups are private channels — join/leave at runtime; broadcast to members only
- ✅ The lifecycle (OnConnectedAsync/OnDisconnectedAsync) tracks presence; never store state in hub fields
- ✅ Transports fall back automatically: WebSockets → SSE → long polling
- ✅ Secure hubs with [Authorize], keep methods non-blocking, and add a backplane for multiple servers
Practice quiz
What is a Hub in SignalR?
- A database table
- A JavaScript library
- The server-side class clients connect to and invoke methods on
- A load balancer
Answer: The server-side class clients connect to and invoke methods on. A Hub is the server class clients connect to; each public method is something a client can call.
Which target sends a message to every connected client?
- Clients.All
- Clients.Caller
- Clients.Others
- Clients.Group(name)
Answer: Clients.All. Clients.All broadcasts to every connected client, like a radio broadcast.
What does Clients.Caller do?
- Sends to everyone
- Sends to a group
- Disconnects the caller
- Replies only to the client that made the call
Answer: Replies only to the client that made the call. Clients.Caller targets only the client that invoked the hub method.
What are SignalR Groups used for?
- Storing user passwords
- Private channels so only group members receive a broadcast
- Speeding up the database
- Replacing WebSockets
Answer: Private channels so only group members receive a broadcast. Groups are named sets of connections; Clients.Group(room) reaches only that group's members.
How do you add the current connection to a group?
- Groups.AddToGroupAsync(Context.ConnectionId, room)
- Clients.Group(room).Add()
- Hub.Join(room)
- Context.AddGroup(room)
Answer: Groups.AddToGroupAsync(Context.ConnectionId, room). Groups.AddToGroupAsync(Context.ConnectionId, room) adds this connection to a named group.
Why should you never store per-user state in hub fields?
- Fields are read-only
- Hubs can't have fields
- A new hub instance is created per call and then discarded
- It would leak memory
Answer: A new hub instance is created per call and then discarded. SignalR creates a fresh hub instance for every call and throws it away, so field state won't survive.
What is true about Context.ConnectionId?
- It is the user's permanent id
- It is per-connection and changes on every reconnect
- It is the same for all clients
- It is the user's email
Answer: It is per-connection and changes on every reconnect. ConnectionId is valid only for one connection and changes on reconnect; never persist it as a user identifier.
What is SignalR's main advantage over client polling?
- It uses less code
- It needs no server
- It encrypts everything
- The server pushes updates over one open connection instead of clients repeatedly asking
Answer: The server pushes updates over one open connection instead of clients repeatedly asking. SignalR keeps one connection open so the server pushes the instant there's news, avoiding wasteful polling.
What is the preferred, fastest transport SignalR negotiates first?
- Long polling
- WebSockets
- Server-Sent Events
- FTP
Answer: WebSockets. WebSockets is a true two-way persistent connection and is preferred; SignalR falls back to SSE then long polling.
When you run more than one server, what is needed so a broadcast reaches all clients?
- A faster CPU
- More memory
- A backplane such as Redis or Azure SignalR
- A second database
Answer: A backplane such as Redis or Azure SignalR. Without a backplane, a broadcast only reaches clients on the same server; Redis/Azure SignalR bridge instances.
Continue this course
- Previous: Caching Strategies (MemoryCache, Distributed Cache, Redis)
- Next: WebSockets & Streaming Data in .NET — Handle raw WebSocket connections and stream large data in .NET
- Quick reference: C# cheat sheet