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

📡 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.

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

TargetWho gets itTypical use
Clients.AllEvery connected clientGlobal broadcast, announcements
Clients.CallerOnly the client that calledAcknowledgements, replies
Clients.OthersEveryone except the caller"User joined / is typing"
Clients.Group(name)Members of that groupChat rooms, channels
Clients.Client(id)One connection by idDirect/private message
Clients.User(userId)All of one user's connectionsNotify 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.

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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Define a hubclass ChatHub : Hub { ... }Server class clients connect to
Broadcast to allClients.All.SendAsync("M", x)Every connected client
Reply to callerClients.Caller.SendAsync(...)Only the sender
Join a groupGroups.AddToGroupAsync(id, "room")id = Context.ConnectionId
Send to a groupClients.Group("room").SendAsync(...)Members only
Map the hubapp.MapHub<ChatHub>("/hubs/chat")In Program.cs
JS: listenconnection.on("M", fn)Before start()
JS: call serverconnection.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

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