46. Real-time with SignalR
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.