🔍 1. Introduction
SignalR is a real-time communication library for ASP.NET Core that enables
server-to-client push notifications. It automatically falls back to WebSockets,
Server-Sent Events, or long polling, making it the ideal solution for real-time features
like notifications, chat, live dashboards, and collaborative editing.
In this guide, we'll build a complete notification system where:
- Users receive real-time notifications without refreshing the page.
- Notifications are user-specific (private).
- Authentication is integrated with JWT.
- Connection management handles reconnection gracefully.
- Notifications are displayed with a toast UI.
💡 Quick fact: SignalR supports WebSockets, which provide full-duplex
communication, but gracefully falls back to other transports if WebSockets aren't available.
🖥️ 2. ASP.NET Core Backend
Let's build the SignalR Hub and configure the backend for real-time notifications.
📌 Step 1: Install SignalR NuGet Package
dotnet add package Microsoft.AspNetCore.SignalR
📌 Step 2: Create Notification Model
public class NotificationDto {
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string Type { get; set; } = "info";
public string? Link { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public bool IsRead { get; set; }
public string? UserId { get; set; }
}
📌 Step 3: Create Notification Hub
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
[Authorize]
public class NotificationHub : Hub {
private readonly ILogger<NotificationHub> _logger;
private static readonly Dictionary<string, HashSet<string>> _userConnections = new();
public NotificationHub(ILogger<NotificationHub> logger) {
_logger = logger;
}
public override Task OnConnectedAsync() {
var userId = Context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userId)) {
lock (_userConnections) {
if (!_userConnections.ContainsKey(userId)) {
_userConnections[userId] = new HashSet<string>();
}
_userConnections[userId].Add(Context.ConnectionId);
}
_logger.LogInformation($"User {userId} connected. ConnectionId: {Context.ConnectionId}");
}
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception) {
var userId = Context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrEmpty(userId)) {
lock (_userConnections) {
if (_userConnections.ContainsKey(userId)) {
_userConnections[userId].Remove(Context.ConnectionId);
if (_userConnections[userId].Count == 0) {
_userConnections.Remove(userId);
}
}
}
_logger.LogInformation($"User {userId} disconnected. ConnectionId: {Context.ConnectionId}");
}
return base.OnDisconnectedAsync(exception);
}
public async Task SendNotificationToUser(string userId, NotificationDto notification) {
notification.UserId = userId;
await Clients.User(userId).SendAsync("ReceiveNotification", notification);
_logger.LogInformation($"Notification sent to user {userId}: {notification.Title}");
}
public async Task BroadcastNotification(NotificationDto notification) {
await Clients.All.SendAsync("ReceiveNotification", notification);
_logger.LogInformation($"Broadcast notification: {notification.Title}");
}
public async Task SendNotificationToGroup(string groupName, NotificationDto notification) {
await Clients.Group(groupName).SendAsync("ReceiveNotification", notification);
_logger.LogInformation($"Notification sent to group {groupName}: {notification.Title}");
}
public async Task JoinGroup(string groupName) {
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation($"Connection {Context.ConnectionId} joined group {groupName}");
}
public async Task LeaveGroup(string groupName) {
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
_logger.LogInformation($"Connection {Context.ConnectionId} left group {groupName}");
}
public Task<string[]> GetOnlineUsers() {
lock (_userConnections) {
return Task.FromResult(_userConnections.Keys.ToArray());
}
}
public Task<int> GetUserConnectionCount(string userId) {
lock (_userConnections) {
return Task.FromResult(_userConnections.ContainsKey(userId) ? _userConnections[userId].Count : 0);
}
}
}
📌 Step 4: Configure SignalR in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddCors(options => {
options.AddPolicy("AllowAngular", policy => {
policy.WithOrigins("http://localhost:4200")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
var app = builder.Build();
app.UseCors("AllowAngular");
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificationHub>("/notificationHub");
app.Run();
📌 Step 5: Send Notification from Anywhere
public class NotificationService {
private readonly IHubContext<NotificationHub> _hubContext;
public NotificationService(IHubContext<NotificationHub> hubContext) {
_hubContext = hubContext;
}
public async Task SendNotification(string userId, string title, string message, string type = "info") {
var notification = new NotificationDto {
Title = title,
Message = message,
Type = type,
CreatedAt = DateTime.UtcNow,
UserId = userId
};
await _hubContext.Clients.User(userId).SendAsync("ReceiveNotification", notification);
}
public async Task Broadcast(string title, string message, string type = "info") {
var notification = new NotificationDto {
Title = title,
Message = message,
Type = type,
CreatedAt = DateTime.UtcNow
};
await _hubContext.Clients.All.SendAsync("ReceiveNotification", notification);
}
}
✅ Done: The backend is ready! Now let's build the Angular client.
🅰️ 3. Angular Frontend
Now let's build the Angular client to connect to SignalR and receive notifications.
📌 Step 1: Install SignalR Client Package
npm install @microsoft/signalr
📌 Step 2: Create Notification Service
import { Injectable } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Subject, Observable } from 'rxjs';
export interface Notification {
id: string;
title: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
link?: string;
createdAt: Date;
isRead: boolean;
}
@Injectable({ providedIn: 'root' })
export class NotificationService {
private hubConnection!: signalR.HubConnection;
private notificationSubject = new Subject<Notification>();
private connectionStatusSubject = new Subject<signalR.HubConnectionState>();
private readonly hubUrl = 'https://localhost:5001/notificationHub';
constructor() {
this.initializeConnection();
}
private initializeConnection(): void {
this.hubConnection = new signalR.HubConnectionBuilder()
.withUrl(this.hubUrl, {
accessTokenFactory: () => localStorage.getItem('access_token') || ''
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 20000])
.configureLogging(signalR.LogLevel.Information)
.build();
this.hubConnection.on('ReceiveNotification', (notification: Notification) => {
notification.createdAt = new Date(notification.createdAt);
this.notificationSubject.next(notification);
});
this.hubConnection.onreconnecting(() => {
this.connectionStatusSubject.next(signalR.HubConnectionState.Reconnecting);
});
this.hubConnection.onreconnected(() => {
this.connectionStatusSubject.next(signalR.HubConnectionState.Connected);
});
this.hubConnection.onclose(() => {
this.connectionStatusSubject.next(signalR.HubConnectionState.Disconnected);
});
}
async start(): Promise<void> {
if (this.hubConnection.state === signalR.HubConnectionState.Disconnected) {
try {
await this.hubConnection.start();
this.connectionStatusSubject.next(signalR.HubConnectionState.Connected);
console.log('SignalR connection established.');
} catch (error) {
console.error('SignalR connection failed:', error);
this.connectionStatusSubject.next(signalR.HubConnectionState.Disconnected);
setTimeout(() => this.start(), 5000);
}
}
}
async stop(): Promise<void> {
if (this.hubConnection.state !== signalR.HubConnectionState.Disconnected) {
try {
await this.hubConnection.stop();
this.connectionStatusSubject.next(signalR.HubConnectionState.Disconnected);
console.log('SignalR connection stopped.');
} catch (error) {
console.error('Failed to stop SignalR:', error);
}
}
}
async joinGroup(groupName: string): Promise<void> {
await this.hubConnection.invoke('JoinGroup', groupName);
}
async leaveGroup(groupName: string): Promise<void> {
await this.hubConnection.invoke('LeaveGroup', groupName);
}
async sendNotificationToUser(userId: string, notification: Partial<Notification>): Promise<void> {
await this.hubConnection.invoke('SendNotificationToUser', userId, notification);
}
async broadcastNotification(notification: Partial<Notification>): Promise<void> {
await this.hubConnection.invoke('BroadcastNotification', notification);
}
getConnectionStatus(): Observable<signalR.HubConnectionState> {
return this.connectionStatusSubject.asObservable();
}
getNotifications(): Observable<Notification> {
return this.notificationSubject.asObservable();
}
isConnected(): boolean {
return this.hubConnection.state === signalR.HubConnectionState.Connected;
}
getConnectionState(): signalR.HubConnectionState {
return this.hubConnection.state;
}
}
📌 Step 3: Notification Toast Component
@Component({
selector: 'app-notification-toast',
templateUrl: './notification-toast.component.html',
styleUrls: ['./notification-toast.component.css'],
standalone: true,
imports: [CommonModule]
})
export class NotificationToastComponent implements OnInit {
notifications: Notification[] = [];
private toastTimeout: any;
constructor(private notificationService: NotificationService) {}
ngOnInit(): void {
this.notificationService.getNotifications().subscribe(notification => {
this.notifications.push(notification);
this.showToast(notification);
setTimeout(() => {
this.removeNotification(notification.id);
}, 5000);
});
}
private showToast(notification: Notification): void {
console.log(`[${notification.type}] ${notification.title}: ${notification.message}`);
}
removeNotification(id: string): void {
this.notifications = this.notifications.filter(n => n.id !== id);
}
getIcon(type: string): string {
const icons: Record<string, string> = {
info: 'ℹ️',
success: '✅',
warning: '⚠️',
error: '❌'
};
return icons[type] || 'ℹ️';
}
getBgColor(type: string): string {
const colors: Record<string, string> = {
info: '#3b82f6',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444'
};
return colors[type] || '#3b82f6';
}
}
📌 Step 4: App Initialization
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
standalone: true,
imports: [CommonModule, RouterOutlet, NotificationToastComponent]
})
export class AppComponent implements OnInit {
connectionStatus = signalR.HubConnectionState.Disconnected;
isConnected = false;
constructor(private notificationService: NotificationService) {}
async ngOnInit(): Promise<void> {
this.notificationService.getConnectionStatus().subscribe(status => {
this.connectionStatus = status;
this.isConnected = status === signalR.HubConnectionState.Connected;
});
if (localStorage.getItem('access_token')) {
await this.notificationService.start();
}
}
async onLogin(): Promise<void> {
await this.notificationService.start();
}
async onLogout(): Promise<void> {
await this.notificationService.stop();
}
}
📌 Step 5: Toast Template
<div class="toast-container">
<div *ngFor="let notif of notifications"
class="toast-item"
[style.border-left-color]="getBgColor(notif.type)"
[class.entering]="true">
<span class="toast-icon">{{ getIcon(notif.type) }}</span>
<div class="toast-content">
<div class="toast-title">{{ notif.title }}</div>
<div class="toast-message">{{ notif.message }}</div>
<div class="toast-time">{{ notif.createdAt | date:'shortTime' }}</div>
</div>
<button class="toast-close" (click)="removeNotification(notif.id)">✕</button>
</div>
</div>
🔐 4. Authentication & Security
SignalR integrates seamlessly with ASP.NET Core authentication. Here's how to secure your
hub and pass JWT tokens from Angular.
📌 Backend: Configure JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.Events = new JwtBearerEvents {
OnMessageReceived = context => {
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/notificationHub")) {
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])
)
};
});
📌 Frontend: Send Token with Connection
The accessTokenFactory in the HubConnectionBuilder automatically adds the
token to the connection request. This works for WebSockets and HTTP fallbacks.
.withUrl(this.hubUrl, {
accessTokenFactory: () => localStorage.getItem('access_token') || ''
})
📌 User Identification
SignalR uses IUserIdProvider to map connections to users. The default
implementation uses NameIdentifier claim. Ensure your JWT includes this claim.
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, user.UserName)
};
👥 5. Groups & User-Specific Notifications
SignalR groups allow you to send notifications to a subset of users. This is useful for
team notifications, project updates, or role-based messaging.
📌 Joining Groups on Connection
You can automatically add users to groups based on their role or team:
public override async Task OnConnectedAsync() {
var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var roles = Context.User?.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value);
if (roles != null) {
foreach (var role in roles) {
await Groups.AddToGroupAsync(Context.ConnectionId, $"role-{role}");
}
}
if (!string.IsNullOrEmpty(userId)) {
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
}
await base.OnConnectedAsync();
}
📌 Sending to Groups
await Clients.Group("role-admin").SendAsync("ReceiveNotification", notification);
await Clients.Group($"user-{userId}").SendAsync("ReceiveNotification", notification);
📌 Angular: Join Group on Login
await this.notificationService.joinGroup('role-admin');
🔄 6. Reconnection & Error Handling
Network interruptions are inevitable. SignalR's automatic reconnection ensures your app
stays connected with minimal disruption.
📌 Automatic Reconnect Configuration
.withAutomaticReconnect([0, 2000, 5000, 10000, 20000])
This specifies the delay in milliseconds between reconnect attempts. The array defines
exponential backoff: 0ms, 2s, 5s, 10s, 20s, then it continues with the last value.
📌 Handling Connection Events
this.hubConnection.onreconnecting((error) => {
console.log('Reconnecting...', error);
this.connectionStatusSubject.next(HubConnectionState.Reconnecting);
});
this.hubConnection.onreconnected((connectionId) => {
console.log('Reconnected with ID:', connectionId);
this.connectionStatusSubject.next(HubConnectionState.Connected);
});
this.hubConnection.onclose((error) => {
console.log('Connection closed:', error);
this.connectionStatusSubject.next(HubConnectionState.Disconnected);
setTimeout(() => this.start(), 5000);
});
📌 UI Status Indicator
<div class="connection-status" [class.connected]="isConnected" [class.reconnecting]="isReconnecting">
<span class="status-dot"></span>
{{ connectionStatusLabel }}
</div>
✅ Best practice: Always show connection status to the user and provide
a manual reconnect button for edge cases.
🔧 7. Advanced Patterns
Here are some advanced patterns for production-ready SignalR applications.
📌 Scaling with Azure SignalR Service
For production, use Azure SignalR Service to handle many concurrent connections and
scale across multiple servers:
builder.Services.AddSignalR().AddAzureSignalR(options => {
options.Endpoints = new ServiceEndpoint[] {
new ServiceEndpoint(builder.Configuration["AzureSignalR:ConnectionString"])
};
});
📌 Message Persistence
Store notifications in a database so users can see past notifications after reconnecting:
public async Task SendNotificationToUser(string userId, NotificationDto notification) {
await _notificationRepository.SaveAsync(notification);
await Clients.User(userId).SendAsync("ReceiveNotification", notification);
}
[HttpGet("api/notifications")]
public async Task<IEnumerable<NotificationDto>> GetNotifications(int count = 50) {
return await _notificationRepository.GetRecentAsync(User.GetUserId(), count);
}
📌 Typed Hubs
Use typed hubs for strongly-typed client methods:
public interface INotificationClient {
Task ReceiveNotification(NotificationDto notification);
Task ReceiveBulkNotifications(List<NotificationDto> notifications);
}
public class NotificationHub : Hub<INotificationClient> {
public async Task SendNotificationToUser(string userId, NotificationDto notification) {
await Clients.User(userId).ReceiveNotification(notification);
}
}
❓ Frequently Asked Questions
🏆 9. Best Practices
- Use WebSockets when possible: SignalR automatically negotiates the best transport, but WebSockets provide the lowest latency.
- Enable automatic reconnection: Use
withAutomaticReconnect() with exponential backoff.
- Authenticate all hubs: Use
[Authorize] on hubs to prevent unauthorized access.
- Send tokens via query string: For WebSockets, tokens are passed via query string or the
accessTokenFactory.
- Clean up on disconnect: Override
OnDisconnectedAsync to clean up group memberships.
- Use groups for scalability: Groups reduce the number of messages sent to individual connections.
- Limit message size: SignalR has a default message size limit. Use
AddSignalR(options => options.MaximumReceiveMessageSize) to adjust.
- Monitor with logging: Enable logging on both client and server for debugging.
- Use
IHubContext for server-to-client communication: This allows you to send notifications from anywhere in your app.
- Implement connection status UI: Always show the user their connection status.
- Use Azure SignalR Service for production: It handles scaling, load balancing, and high availability.
✅ Final thought: SignalR is a powerful tool for building real-time features.
With the implementation in this guide, you can build a robust notification system that
scales from small projects to enterprise applications. Remember to monitor performance,
handle errors gracefully, and always prioritize security.
0 Comments
thanks for your comments!