User-specific notifications — “someone commented on your post” — require targeting a specific connected client rather than a group. SignalR’s Clients.User(userId) sends to all connections of a specific user (they may be logged in on multiple devices). Notifications must also persist in the database for users who are not currently connected — the SignalR push is the real-time delivery mechanism, and the database is the durable fallback.
Notifications System
// ── Notification types ─────────────────────────────────────────────────────
public record NotificationDto(
int Id,
string Title,
string? Message,
string? ActionUrl,
bool IsRead,
DateTime CreatedAt
);
// ── Server: send notifications from API services (not just hub methods) ────
// Inject IHubContext to send from outside the hub:
public class CommentsService
{
private readonly IHubContext<BlogHub, IBlogHubClient> _hub;
private readonly INotificationsRepository _notifications;
// Called when a REST API POST /api/posts/{id}/comments is used instead of hub
public async Task<CommentDto> CreateAsync(int postId, string authorId, string body, CancellationToken ct)
{
var comment = /* save to DB */;
// Broadcast to post room viewers
await _hub.Clients.Group($"post-{postId}")
.ReceiveComment(comment.ToDto());
// Persist notification for the post author
if (comment.PostAuthorId != authorId)
{
var notification = await _notifications.CreateAsync(
userId: comment.PostAuthorId,
title: $"{comment.AuthorName} commented on '{comment.PostTitle}'",
actionUrl: $"/posts/{comment.PostSlug}",
ct: ct);
// Push to author if currently connected (fire-and-forget — not awaited)
_ = _hub.Clients.User(comment.PostAuthorId)
.ReceiveNotification(notification.ToDto());
}
return comment.ToDto();
}
}
// ── Angular: NotificationsComponent ──────────────────────────────────────
@Component({
selector: 'app-notifications',
standalone: true,
imports: [MatBadgeModule, MatMenuModule, MatButtonModule, DatePipe, RouterLink],
template: `
<!-- Bell icon with unread badge ────────────────────────────────────── -->
<button mat-icon-button [matMenuTriggerFor]="notifMenu"
(click)="onBellClick()">
<mat-icon [matBadge]="unreadCount()" matBadgeColor="warn"
[matBadgeHidden]="unreadCount() === 0">
notifications
</mat-icon>
</button>
<mat-menu #notifMenu="matMenu" class="notifications-menu">
@if (notifications().length === 0) {
<p class="empty">No notifications</p>
} @else {
@for (n of notifications(); track n.id) {
<a mat-menu-item [routerLink]="n.actionUrl"
[class.unread]="!n.isRead"
(click)="markRead(n.id)">
<span class="title">{{ n.title }}</span>
<time>{{ n.createdAt | date:'short' }}</time>
</a>
}
<button mat-menu-item (click)="markAllRead()">Mark all as read</button>
}
</mat-menu>
`,
})
export class NotificationsComponent implements OnInit {
private signalR = inject(SignalRService);
private notifApi = inject(NotificationsApiService);
private destroyRef = inject(DestroyRef);
notifications = signal<NotificationDto[]>([]);
unreadCount = computed(() => this.notifications().filter(n => !n.isRead).length);
ngOnInit() {
// Load existing notifications from REST API
this.notifApi.getRecent(20).subscribe(list => this.notifications.set(list));
// Real-time: add new notifications as they arrive
this.signalR.notifications$.pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe(notification => {
this.notifications.update(list => [notification, ...list]);
// Optional: play a sound or show a browser notification
});
}
markRead(id: number): void {
this.notifApi.markRead(id).subscribe(() =>
this.notifications.update(list =>
list.map(n => n.id === id ? { ...n, isRead: true } : n)));
}
markAllRead(): void {
this.notifApi.markAllRead().subscribe(() =>
this.notifications.update(list =>
list.map(n => ({ ...n, isRead: true }))));
}
}
IHubContext<BlogHub, IBlogHubClient> allows sending SignalR messages from anywhere in the application — services, background jobs, event handlers — not just from within hub methods. It is the bridge between non-hub code and the SignalR connection. The strongly-typed version (IHubContext<THub, TClient>) provides the same compile-time safety as Hub<TClient>. Register it via DI automatically when you call AddSignalR() — no additional registration needed._ = _hub.Clients.User(...).ReceiveNotification(...) pattern (discarding the Task with _ =) is intentional fire-and-forget for the SignalR push. The notification is already persisted in the database — the SignalR delivery is best-effort (the user may not be connected). Awaiting the SignalR call would make the comment creation wait for the push, which is unnecessary. Use fire-and-forget for all non-critical SignalR pushes where the database is the durable store.Clients.User(userId) sends to ALL active connections for that user. If a user is logged in on their phone, tablet, and laptop simultaneously, all three receive the notification. This is usually the desired behaviour. However, for operations like “mark as read” that update state, ensure the read state is persisted to the database first — then broadcast the state change via SignalR to all user connections so the badge count updates on all devices simultaneously.Common Mistakes
Mistake 1 — Only pushing via SignalR without database persistence (notification lost if user offline)
❌ Wrong — push via SignalR only; user is offline; notification lost; they never see it.
✅ Correct — persist to database first; push via SignalR for real-time delivery; REST API for loading history on reconnect.
Mistake 2 — Awaiting non-critical SignalR pushes (blocks request for disconnected users)
❌ Wrong — await _hub.Clients.User(userId).SendAsync(...); if user disconnected, awaits timeout; delays response to the sender.
✅ Correct — _ = _hub.Clients.User(userId).SendAsync(...); fire-and-forget; response returns immediately.