skills/api/signalr-realtime/SKILL.md
Use when adding real-time communication with SignalR hubs, WebSocket connections, or push notifications.
npx skillsauth add faysilalshareef/dotnet-ai-kit signalr-realtimeInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Hub<T>) with a client interface for compile-time safetyOnConnectedAsync / OnDisconnectedAsync) for presence and cleanupDefine a typed client interface and a strongly-typed hub:
// Contracts/INotificationClient.cs
public interface INotificationClient
{
Task ReceiveNotification(NotificationMessage message);
Task OrderStatusChanged(Guid orderId, string status);
Task UserJoined(string userName);
Task UserLeft(string userName);
}
public sealed record NotificationMessage(
string Title,
string Body,
string Severity,
DateTimeOffset Timestamp);
// Hubs/NotificationHub.cs
[Authorize]
public sealed class NotificationHub(
ILogger<NotificationHub> logger)
: Hub<INotificationClient>
{
public async Task SendToGroup(string groupName,
NotificationMessage message)
{
logger.LogInformation(
"Sending notification to group {Group}", groupName);
await Clients.Group(groupName)
.ReceiveNotification(message);
}
public async Task SendToUser(string userId,
NotificationMessage message)
{
await Clients.User(userId)
.ReceiveNotification(message);
}
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
logger.LogInformation(
"User {UserId} connected: {ConnectionId}",
userId, Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(
Exception? exception)
{
logger.LogInformation(
"User {UserId} disconnected: {ConnectionId}",
Context.UserIdentifier, Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
// Program.cs
builder.Services.AddSignalR();
var app = builder.Build();
app.MapHub<NotificationHub>("/hubs/notifications");
Use groups to target messages to logical sets of users:
public sealed class OrderHub(
ILogger<OrderHub> logger)
: Hub<IOrderClient>
{
public async Task JoinOrderGroup(Guid orderId)
{
var groupName = $"order-{orderId}";
await Groups.AddToGroupAsync(
Context.ConnectionId, groupName);
logger.LogInformation(
"Connection {ConnectionId} joined group {Group}",
Context.ConnectionId, groupName);
}
public async Task LeaveOrderGroup(Guid orderId)
{
var groupName = $"order-{orderId}";
await Groups.RemoveFromGroupAsync(
Context.ConnectionId, groupName);
}
}
// Sending from a service using IHubContext
public sealed class OrderService(
IHubContext<OrderHub, IOrderClient> hubContext)
{
public async Task UpdateOrderStatus(
Guid orderId, string status)
{
// Business logic here...
await hubContext.Clients
.Group($"order-{orderId}")
.OrderStatusChanged(orderId, status);
}
}
Apply [Authorize] on the hub and pass bearer tokens via query string for WebSocket transport:
// Program.cs — configure JWT to read token from query string
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(
builder.Configuration["Jwt:Key"]!))
};
// SignalR sends token as query string for WebSocket
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("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// Hub with role-based authorization
[Authorize(Roles = "Admin,Manager")]
public sealed class AdminHub : Hub<IAdminClient>
{
[Authorize(Policy = "CanManageUsers")]
public async Task BroadcastAlert(string message)
{
await Clients.All.AlertReceived(message);
}
}
Track connections for presence features and cleanup:
public sealed class PresenceHub(
IConnectionTracker tracker)
: Hub<IPresenceClient>
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier
?? throw new HubException("User not authenticated");
await tracker.AddConnectionAsync(
userId, Context.ConnectionId);
await Clients.All.OnlineUsersUpdated(
await tracker.GetOnlineUsersAsync());
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(
Exception? exception)
{
var userId = Context.UserIdentifier!;
await tracker.RemoveConnectionAsync(
userId, Context.ConnectionId);
if (!await tracker.IsOnlineAsync(userId))
await Clients.Others.UserLeft(userId);
await base.OnDisconnectedAsync(exception);
}
}
Implement IConnectionTracker with a ConcurrentDictionary<string, HashSet<string>> mapping user IDs to connection IDs. Use an external store (Redis) for multi-server deployments.
// npm install @microsoft/signalr
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", {
accessTokenFactory: () => localStorage.getItem("token")
})
.withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
.configureLogging(signalR.LogLevel.Information)
.build();
// Register handlers before starting
connection.on("ReceiveNotification", (message) => {
console.log(`${message.title}: ${message.body}`);
});
connection.onreconnecting(() => console.warn("Reconnecting..."));
connection.onclose((err) => console.error("Connection closed:", err));
async function start() {
try {
await connection.start();
await connection.invoke("JoinOrderGroup", orderId);
} catch (err) {
console.error("Connection failed:", err);
setTimeout(start, 5000);
}
}
start();
// Install: Microsoft.AspNetCore.SignalR.Client
var connection = new HubConnectionBuilder()
.WithUrl("https://localhost:5001/hubs/notifications",
options =>
{
options.AccessTokenProvider =
() => Task.FromResult(token);
})
.WithAutomaticReconnect()
.Build();
connection.On<NotificationMessage>(
"ReceiveNotification", message =>
Console.WriteLine($"{message.Title}: {message.Body}"));
connection.Closed += async _ =>
{
await Task.Delay(Random.Shared.Next(0, 5) * 1000);
await connection.StartAsync();
};
await connection.StartAsync();
await connection.InvokeAsync("SendToGroup", "admins",
new NotificationMessage("Alert", "Server load high",
"Warning", DateTimeOffset.UtcNow));
Scale SignalR across multiple servers with a Redis backplane:
// Install: Microsoft.AspNetCore.SignalR.StackExchangeRedis
builder.Services.AddSignalR()
.AddStackExchangeRedis(
builder.Configuration
.GetConnectionString("Redis")!,
options =>
{
options.Configuration.ChannelPrefix =
RedisChannel.Literal("MyApp");
});
For Azure deployments, use the Azure SignalR Service:
// Install: Microsoft.Azure.SignalR
builder.Services.AddSignalR()
.AddAzureSignalR(
builder.Configuration
.GetConnectionString("AzureSignalR")!);
| Scenario | Recommendation |
|----------|---------------|
| Single server, few clients | In-process SignalR, no backplane needed |
| Multi-server deployment | Add Redis backplane or Azure SignalR Service |
| Public-facing, high scale | Azure SignalR Service (serverless or default mode) |
| Authenticated users only | [Authorize] on hub + JWT via query string |
| Targeting specific users | Use Clients.User(userId) with IUserIdProvider |
| Targeting logical groups | Use Groups.AddToGroupAsync + Clients.Group() |
| Broadcasting from services | Inject IHubContext<THub, TClient> |
| Client is a browser SPA | @microsoft/signalr npm package with auto-reconnect |
| Client is another .NET app | Microsoft.AspNetCore.SignalR.Client NuGet |
| Anti-Pattern | Problem | Fix |
|--------------|---------|-----|
| Untyped Hub with magic strings | Runtime errors, no IntelliSense | Use Hub<T> with typed client interface |
| Storing state in hub fields | Hubs are transient, state lost per call | Use IConnectionTracker or external store |
| Blocking calls in hub methods | Starves SignalR thread pool | Always use async/await |
| Missing [Authorize] on hub | Unauthenticated access to real-time data | Add [Authorize] and configure JWT |
| No reconnect on client | Silent disconnection, missed messages | Use withAutomaticReconnect() |
| Sending large payloads | WebSocket frame limits, slow clients | Send notification + fetch data via REST |
| Not using groups for multi-tenant | Cross-tenant data leaks | Assign users to tenant groups on connect |
| Calling Clients.All for targeted messages | Unnecessary traffic to unrelated clients | Use Clients.Group() or Clients.User() |
Hub< or : Hub class inheritance in hub filesMapHub< in Program.csAddSignalR() in service registration@microsoft/signalr in package.jsonIHubContext< injections in servicesAddStackExchangeRedis or AddAzureSignalR for scaling configMicrosoft.AspNetCore.SignalR (included in ASP.NET Core shared framework)Hub<TClient> with [Authorize]builder.Services.AddSignalR() and app.MapHub<THub>("/hubs/...")OnMessageReceived to read access_token from query string@microsoft/signalr for JS or SignalR.Client NuGet for .NETdata-ai
Use when about to claim work is complete, fixed, passing, or ready — before committing, creating PRs, or moving to the next task. Requires running verification commands and confirming output before making any success claims.
development
Use when encountering any bug, test failure, build error, or unexpected behavior — before proposing fixes or making changes.
development
Use when checkpointing, wrapping up, or handing off an AI-assisted development session.
development
Use when following the Specification-Driven Development lifecycle from plan through ship.