46. Real-time with SignalR

Building real-time features with SignalR: hubs, push notifications, broadcasting, and client integration

About this chapter

Build real-time features using SignalR to push notifications from server to clients instantly, eliminating the need for polling and creating responsive user experiences.

  • Real-time fundamentals: Push vs polling and performance benefits
  • SignalR hubs: Server components that handle bidirectional communication
  • Client connections: Connecting clients to hubs with automatic reconnection
  • Broadcasting: Sending messages to multiple clients or groups
  • Typed hubs: Using strongly-typed hub interfaces for client calls
  • Integration with existing APIs: Using SignalR alongside REST endpoints

Learning outcomes:

  • Understand real-time communication benefits
  • Create SignalR hubs for server-to-client messaging
  • Connect JavaScript clients to hubs
  • Broadcast messages to groups or all clients
  • Handle connections and disconnections
  • Combine SignalR with existing REST APIs

46.1 Why Real-time?

Without real-time (polling):

Client: "Is my job done yet?"
  ↓ (every 1 second)
Server: "No"
Client: "Is my job done yet?"
  ↓ (every 1 second)
Server: "No"
Client: "Is my job done yet?"
  ↓ (every 1 second)
Server: "Yes!"

Problems:

  • Inefficient: Hundreds of unnecessary requests
  • Latency: Job finishes, client doesn’t know for up to 1 second
  • Battery drain: Mobile clients constantly polling
  • Server load: Wasted resources on “Is it done yet?”

With real-time (push):

Job starts
  ↓
Job completes
  ↓ (server pushes)
Server: "Job complete!"
Client receives instantly

Benefits:

  • Instant: User sees updates immediately
  • Efficient: Only messages when something happens
  • Scalable: Server pushes instead of client pulls
  • Better UX: Live updates create responsive feeling

46.2 SignalR Introduction

SignalR is ASP.NET Core’s real-time communication framework.

How it works:

Client                          Server
  ↓                               ↓
WebSocket (preferred)
  ↓←−−−−−−Bidirectional−−−−−−→↓
  ↓                               ↓
Server Sent Events (fallback)
  ↓←−−−−−−Unidirectional−−−−−→↓
  ↓                               ↓
Long polling (last resort)
  ↓←−−−−−−Back & Forth−−−−−→↓

SignalR automatically chooses the best transport.

Key concepts:

  • Hub: Server endpoint for real-time communication
  • Client method: JavaScript function server can call
  • Server method: C# method client can call
  • Group: Broadcast to subset of clients
  • Connection ID: Unique identifier per client

46.3 Installing SignalR

Add NuGet packages:

dotnet add package Microsoft.AspNetCore.SignalR

46.4 Creating a SignalR Hub

Hub for job notifications:

// Hubs/JobHubcs
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;

public class JobHub : Hub
{
    private readonly ILogger<JobHub> _logger;

    public JobHub(ILogger<JobHub> logger)
    {
        _logger = logger;
    }

    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("Client {ConnectionId} connected", Context.ConnectionId);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception exception)
    {
        _logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
        await base.OnDisconnectedAsync(exception);
    }

    // Method client can call
    public async Task SubscribeToJob(int jobId)
    {
        _logger.LogInformation("Client {ConnectionId} subscribing to job {JobId}", 
            Context.ConnectionId, jobId);

        // Add client to a group (for broadcasting)
        await Groups.AddToGroupAsync(Context.ConnectionId, $"job-{jobId}");

        // Send confirmation
        await Clients.Caller.SendAsync("SubscriptionConfirmed", jobId);
    }

    public async Task UnsubscribeFromJob(int jobId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"job-{jobId}");
        await Clients.Caller.SendAsync("UnsubscriptionConfirmed", jobId);
    }
}

Register hub in Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR();

var app = builder.Build();

// Map SignalR hubs
app.MapHub<JobHub>("/hubs/job");

app.Run();

Hub is now available at ws://localhost:5000/hubs/job (WebSocket).

46.5 Push Notifications from Background Jobs

Notify client when job completes:

// Services/IJobNotificationService.cs
public interface IJobNotificationService
{
    Task NotifyJobStartedAsync(int jobId);
    Task NotifyJobProgressAsync(int jobId, int progress);
    Task NotifyJobCompletedAsync(int jobId, object result);
    Task NotifyJobFailedAsync(int jobId, string error);
}

// Services/JobNotificationService.cs
using Microsoft.AspNetCore.SignalR;

public class JobNotificationService : IJobNotificationService
{
    private readonly IHubContext<JobHub> _hubContext;
    private readonly ILogger<JobNotificationService> _logger;

    public JobNotificationService(IHubContext<JobHub> hubContext, ILogger<JobNotificationService> logger)
    {
        _hubContext = hubContext;
        _logger = logger;
    }

    public async Task NotifyJobStartedAsync(int jobId)
    {
        _logger.LogInformation("Job {JobId} started", jobId);

        // Broadcast to all clients in job-{jobId} group
        await _hubContext.Clients.Group($"job-{jobId}")
            .SendAsync("JobStarted", jobId);
    }

    public async Task NotifyJobProgressAsync(int jobId, int progress)
    {
        _logger.LogInformation("Job {JobId} progress: {Progress}%", jobId, progress);

        await _hubContext.Clients.Group($"job-{jobId}")
            .SendAsync("JobProgress", jobId, progress);
    }

    public async Task NotifyJobCompletedAsync(int jobId, object result)
    {
        _logger.LogInformation("Job {JobId} completed", jobId);

        await _hubContext.Clients.Group($"job-{jobId}")
            .SendAsync("JobCompleted", jobId, result);
    }

    public async Task NotifyJobFailedAsync(int jobId, string error)
    {
        _logger.LogError("Job {JobId} failed: {Error}", jobId, error);

        await _hubContext.Clients.Group($"job-{jobId}")
            .SendAsync("JobFailed", jobId, error);
    }
}

// Register in Program.cs
builder.Services.AddScoped<IJobNotificationService, JobNotificationService>();

Use in background job:

// Jobs/GenerateReportJob.cs
public class GenerateReportJob
{
    private readonly IReportService _reportService;
    private readonly IJobNotificationService _notificationService;
    private readonly ILogger<GenerateReportJob> _logger;

    public GenerateReportJob(
        IReportService reportService,
        IJobNotificationService notificationService,
        ILogger<GenerateReportJob> logger)
    {
        _reportService = reportService;
        _notificationService = notificationService;
        _logger = logger;
    }

    public async Task GenerateReportAsync(int reportId)
    {
        try
        {
            // Notify job started
            await _notificationService.NotifyJobStartedAsync(reportId);

            // Generate report with progress updates
            var report = new Report { Id = reportId };

            for (int i = 0; i <= 100; i += 10)
            {
                // Simulate work
                await Task.Delay(1000);

                // Send progress
                await _notificationService.NotifyJobProgressAsync(reportId, i);
            }

            // Complete and save
            var result = await _reportService.SaveReportAsync(report);

            // Notify completion
            await _notificationService.NotifyJobCompletedAsync(reportId, new
            {
                Id = reportId,
                Filename = $"report-{reportId}.pdf",
                Size = "2.5 MB",
                Url = $"/files/report-{reportId}.pdf"
            });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Report generation failed");
            await _notificationService.NotifyJobFailedAsync(reportId, ex.Message);
            throw;
        }
    }
}

46.6 Broadcasting to Groups

Notify multiple subscribed clients:

// Hub method
public async Task BroadcastCommandUpdate(int commandId, CommandUpdateDto update)
{
    _logger.LogInformation("Broadcasting update for command {CommandId}", commandId);

    // Send to all clients subscribed to this command
    await Clients.Group($"command-{commandId}")
        .SendAsync("CommandUpdated", commandId, update);
}

// Send to all clients except sender
await Clients.GroupExcept($"command-{commandId}", Context.ConnectionId)
    .SendAsync("CommandUpdated", commandId, update);

// Send to all connected clients
await Clients.All
    .SendAsync("SystemNotification", "Server maintenance in 5 minutes");

// Send to specific client
await Clients.Client(connectionId)
    .SendAsync("PrivateNotification", "Your job is complete");

46.7 Client Integration (JavaScript)

Connect and subscribe:

<!DOCTYPE html>
<html>
<head>
    <title>Job Monitor</title>
    <script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/signalr.js"></script>
</head>
<body>
    <div id="status">Connecting...</div>
    <div id="progress"></div>
    <div id="result"></div>

    <script>
        // Create connection
        const connection = new signalR.HubConnectionBuilder()
            .withUrl("/hubs/job")  // Must match MapHub route
            .withAutomaticReconnect()
            .build();

        // Connect
        connection.start().then(() => {
            console.log("Connected to server");
            document.getElementById("status").innerText = "Connected";

            // Subscribe to job
            connection.invoke("SubscribeToJob", 1);
        }).catch(err => console.log(err));

        // Handle server-sent messages
        connection.on("SubscriptionConfirmed", (jobId) => {
            console.log("Subscribed to job " + jobId);
        });

        connection.on("JobStarted", (jobId) => {
            console.log("Job " + jobId + " started");
            document.getElementById("status").innerText = "Job started...";
        });

        connection.on("JobProgress", (jobId, progress) => {
            console.log("Job " + jobId + " progress: " + progress + "%");
            document.getElementById("progress").innerText = progress + "%";
        });

        connection.on("JobCompleted", (jobId, result) => {
            console.log("Job " + jobId + " completed");
            document.getElementById("status").innerText = "✓ Complete";
            document.getElementById("result").innerText = 
                "Download: " + result.filename;
        });

        connection.on("JobFailed", (jobId, error) => {
            console.error("Job " + jobId + " failed: " + error);
            document.getElementById("status").innerText = "✗ Failed: " + error;
        });

        // Handle disconnection
        connection.onclose(() => {
            console.log("Disconnected from server");
            document.getElementById("status").innerText = "Disconnected";
        });
    </script>
</body>
</html>

46.8 Angular Integration

SignalR in Angular:

// services/job-notification.service.ts
import { Injectable } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Subject, Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class JobNotificationService {
    private connection: signalR.HubConnection;

    private jobProgressSubject = new Subject<{ jobId: number; progress: number }>();
    private jobCompletedSubject = new Subject<{ jobId: number; result: any }>();
    private jobFailedSubject = new Subject<{ jobId: number; error: string }>();

    constructor() {
        this.connection = new signalR.HubConnectionBuilder()
            .withUrl("/hubs/job")
            .withAutomaticReconnect()
            .build();

        this.setupListeners();
    }

    private setupListeners() {
        this.connection.on("JobProgress", (jobId, progress) => {
            this.jobProgressSubject.next({ jobId, progress });
        });

        this.connection.on("JobCompleted", (jobId, result) => {
            this.jobCompletedSubject.next({ jobId, result });
        });

        this.connection.on("JobFailed", (jobId, error) => {
            this.jobFailedSubject.next({ jobId, error });
        });
    }

    connect(): Promise<void> {
        return this.connection.start();
    }

    subscribeToJob(jobId: number): Promise<void> {
        return this.connection.invoke("SubscribeToJob", jobId);
    }

    getProgress(): Observable<{ jobId: number; progress: number }> {
        return this.jobProgressSubject.asObservable();
    }

    getCompleted(): Observable<{ jobId: number; result: any }> {
        return this.jobCompletedSubject.asObservable();
    }

    getFailed(): Observable<{ jobId: number; error: string }> {
        return this.jobFailedSubject.asObservable();
    }
}

// components/job-monitor.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { JobNotificationService } from '../services/job-notification.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
    selector: 'app-job-monitor',
    templateUrl: './job-monitor.component.html'
})
export class JobMonitorComponent implements OnInit, OnDestroy {
    jobId = 1;
    progress = 0;
    status = 'Connecting...';
    result: any = null;
    error: string = null;

    private destroy$ = new Subject<void>();

    constructor(private jobNotificationService: JobNotificationService) {}

    ngOnInit() {
        this.jobNotificationService.connect().then(() => {
            this.status = 'Connected';
            this.jobNotificationService.subscribeToJob(this.jobId);
        });

        // Listen to progress updates
        this.jobNotificationService.getProgress()
            .pipe(takeUntil(this.destroy$))
            .subscribe(data => {
                if (data.jobId === this.jobId) {
                    this.progress = data.progress;
                }
            });

        // Listen to completion
        this.jobNotificationService.getCompleted()
            .pipe(takeUntil(this.destroy$))
            .subscribe(data => {
                if (data.jobId === this.jobId) {
                    this.status = '✓ Complete';
                    this.result = data.result;
                }
            });

        // Listen to failures
        this.jobNotificationService.getFailed()
            .pipe(takeUntil(this.destroy$))
            .subscribe(data => {
                if (data.jobId === this.jobId) {
                    this.status = '✗ Failed';
                    this.error = data.error;
                }
            });
    }

    ngOnDestroy() {
        this.destroy$.next();
        this.destroy$.complete();
    }
}

46.9 Authentication with SignalR

Secure hub connections:

// Hubs/JobHub.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

[Authorize]  // Require authentication
public class JobHub : Hub
{
    private readonly ILogger<JobHub> _logger;

    public JobHub(ILogger<JobHub> logger)
    {
        _logger = logger;
    }

    public override async Task OnConnectedAsync()
    {
        var userId = Context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
        _logger.LogInformation("User {UserId} connected", userId);
        await base.OnConnectedAsync();
    }

    [Authorize(Roles = "Admin")]  // Restrict by role
    public async Task BroadcastSystemNotification(string message)
    {
        await Clients.All.SendAsync("SystemNotification", message);
    }
}

// Configure in Program.cs
app.UseRouting();
app.UseAuthentication();  // Must be before UseAuthorization
app.UseAuthorization();

app.MapHub<JobHub>("/hubs/job");

Client authentication:

// Get JWT token
const token = localStorage.getItem('jwt_token');

// Connect with token
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/job", {
        accessTokenFactory: () => token
    })
    .withAutomaticReconnect()
    .build();

46.10 Scaling with Redis

For multi-server deployments, use Redis backplane:

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis

Configure Redis backplane:

// Program.cs
builder.Services.AddSignalR()
    .AddStackExchangeRedis(options =>
    {
        options.ConnectionFactory = async writer =>
        {
            var config = ConfigurationOptions.Parse("localhost:6379");
            return await ConnectionMultiplexer.ConnectAsync(config);
        };
    });

Now SignalR messages sync across all servers via Redis.

46.11 Testing SignalR Hubs

Test hub methods:

// Tests/Hubs/JobHubTests.cs
using Microsoft.AspNetCore.SignalR;
using Xunit;
using Moq;

public class JobHubTests
{
    [Fact]
    public async Task SubscribeToJob_AddsClientToGroup()
    {
        // Arrange
        var jobId = 1;
        var connectionId = "connection-123";

        var mockGroups = new Mock<IGroupManager>();
        var mockClients = new Mock<IHubClients>();
        var mockCaller = new Mock<IClientProxy>();

        var hub = new JobHub(new Mock<ILogger<JobHub>>().Object)
        {
            Context = new HubCallerContext(
                new DefaultHubCallerContext(new Mock<HubConnectionContext>().Object))
            {
                ConnectionId = connectionId
            },
            Groups = mockGroups.Object,
            Clients = mockClients.Object
        };

        mockClients.Setup(x => x.Caller).Returns(mockCaller.Object);

        // Act
        await hub.SubscribeToJob(jobId);

        // Assert
        mockGroups.Verify(
            x => x.AddToGroupAsync(connectionId, $"job-{jobId}", It.IsAny<CancellationToken>()),
            Times.Once);

        mockCaller.Verify(
            x => x.SendAsync("SubscriptionConfirmed", jobId, It.IsAny<CancellationToken>()),
            Times.Once);
    }
}

46.12 Best Practices

1. Use groups for broadcasting:

// ❌ WRONG - Send to each client individually
foreach (var connectionId in connectedClients)
{
    await Clients.Client(connectionId).SendAsync("Update", data);
}

// ✓ CORRECT - Send to group
await Clients.Group("subscribers").SendAsync("Update", data);

2. Handle reconnection:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/job")
    .withAutomaticReconnect([
        0,     // Reconnect immediately
        1000,  // After 1 second
        5000,  // After 5 seconds
        null   // Give up after 3 attempts
    ])
    .build();

3. Unsubscribe on cleanup:

ngOnDestroy() {
    this.jobNotificationService.unsubscribeFromJob(this.jobId);
}

4. Log SignalR messages:

var connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/job")
    .configureLogging(signalR.LogLevel.Information)
    .build();

46.13 What’s Next

You now have:

  • ✓ Understanding why real-time matters
  • ✓ SignalR fundamentals
  • ✓ Creating hubs and methods
  • ✓ Push notifications from background jobs
  • ✓ Broadcasting to groups
  • ✓ JavaScript client integration
  • ✓ Angular integration with RxJS
  • ✓ Authentication and authorization
  • ✓ Scaling with Redis backplane
  • ✓ Testing hubs
  • ✓ Best practices

Next: File Operations—Upload, validate, stream, and download files safely.