Index · Additional notes
2 min readRapid overview
- Additional notes
- SignalR vs Raw WebSockets vs SSE
- When to Use Each
- Server Configuration
- Basic Setup with Authentication
- Skip Negotiation (WebSocket-Only Mode)
- Scaling with Redis Backplane
- Configuration
- How It Works
- Sticky Sessions Alternative
- Sending Notifications from Background Services
- Multi-Tenant Architecture
- Service Workers and OS Notifications
- Service Worker (Display Only)
- Main Thread Integration
- Best Practices
- 1. Connection Management
- 2. Error Handling
- 3. Message Size and Batching
- 4. Authentication Token Refresh
- 5. Graceful Degradation
Additional notes
SignalR vs Raw WebSockets vs SSE
| Feature | SignalR | Raw WebSockets | SSE |
|---|---|---|---|
| Direction | Bi-directional | Bi-directional | Server to client only |
| Transport | Auto-selects (WS → SSE → LP) | WebSocket only | HTTP |
| Connection Management | Built-in (reconnect, groups) | Manual | Manual |
| Scaling | Redis/Azure backplane | Custom implementation | Simpler |
| Binary Support | Yes (MessagePack) | Yes | No (text only) |
| Browser Fallback | Automatic | None | Good |
| Best For | Enterprise apps, notifications | Gaming, custom protocols | Simple streams |
When to Use Each
Choose SignalR when:
- Building enterprise applications that need reliability and fallbacks
- You need connection management, groups, and broadcasting
- Horizontal scaling is required
- Development speed is a priority
Choose Raw WebSockets when:
- Maximum performance and minimal overhead are critical
- You have a custom protocol requirement
- Binary streaming is the primary use case
- You control both client and server completely
Choose SSE when:
- Server-to-client only (notifications, live feeds)
- Simplicity is more important than bi-directional communication
- You need excellent HTTP/2 support
- Infrastructure constraints prevent WebSocket connections
Server Configuration
Basic Setup with Authentication
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.MaximumReceiveMessageSize = 64 * 1024; // 64KB
});
// Add Redis backplane for horizontal scaling
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("notifications");
});
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
// SignalR sends token via query string for WebSocket connections
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs/notifications"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<NotificationHub>("/hubs/notifications")
.RequireAuthorization();
app.Run();
Skip Negotiation (WebSocket-Only Mode)
Skip negotiation eliminates the initial HTTP request that determines transport:
// Server configuration - WebSocket only
app.MapHub<NotificationHub>("/hubs/notifications", options =>
{
options.Transports = HttpTransportType.WebSockets;
});
Benefits of Skip Negotiation:
- Eliminates one round-trip (faster connection)
- Simpler network path
- Better for controlled environments
Trade-offs:
- No fallback if WebSocket fails
- Must ensure WebSocket support in infrastructure
- Client must explicitly configure WebSocket-only
Scaling with Redis Backplane
For horizontal scaling, SignalR needs a backplane to distribute messages across server instances:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Server 1 │ │ Server 2 │ │ Server 3 │
│ (SignalR) │ │ (SignalR) │ │ (SignalR) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
└───────────────────┼───────────────────┘
│
┌──────┴──────┐
│ Redis │
│ Backplane │
└─────────────┘
Configuration
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal("app:signalr");
options.Configuration.AbortOnConnectFail = false;
});
How It Works
- Client connects to any server instance (via load balancer)
- When server sends a message to a group, it publishes to Redis
- All server instances subscribed to Redis receive the message
- Each server delivers to its locally connected clients in that group
Sticky Sessions Alternative
Without a backplane, you need sticky sessions:
# NGINX sticky session configuration
upstream signalr_servers {
ip_hash; # Sticky sessions based on client IP
server server1:5000;
server server2:5000;
server server3:5000;
}
Redis Backplane vs Sticky Sessions:
| Aspect | Redis Backplane | Sticky Sessions |
|---|---|---|
| Failover | Seamless | Connection lost |
| Scaling | Any instance | Limited by stickiness |
| Complexity | Requires Redis | Simpler infrastructure |
| Cost | Additional component | No extra cost |
Sending Notifications from Background Services
Use IHubContext to send messages from outside the Hub:
public class NotificationBackgroundService : BackgroundService
{
private readonly IHubContext<NotificationHub> _hubContext;
private readonly IMessageQueue _queue;
private readonly ILogger<NotificationBackgroundService> _logger;
public NotificationBackgroundService(
IHubContext<NotificationHub> hubContext,
IMessageQueue queue,
ILogger<NotificationBackgroundService> logger)
{
_hubContext = hubContext;
_queue = queue;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in _queue.ReadAllAsync(stoppingToken))
{
try
{
await ProcessNotification(message, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process notification {Id}", message.Id);
}
}
}
private async Task ProcessNotification(NotificationMessage message, CancellationToken ct)
{
var notification = new
{
message.Id,
message.Title,
message.Body,
message.Type,
Timestamp = DateTimeOffset.UtcNow
};
switch (message.Target)
{
case NotificationTarget.User:
await _hubContext.Clients
.Group($"user:{message.UserId}")
.SendAsync("ReceiveNotification", notification, ct);
break;
case NotificationTarget.Tenant:
await _hubContext.Clients
.Group($"tenant:{message.TenantId}")
.SendAsync("ReceiveNotification", notification, ct);
break;
case NotificationTarget.All:
await _hubContext.Clients.All
.SendAsync("ReceiveNotification", notification, ct);
break;
}
}
}
Multi-Tenant Architecture
For SaaS applications, organize connections by tenant:
public class MultiTenantNotificationHub : Hub
{
public override async Task OnConnectedAsync()
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var roles = Context.User?.FindAll(ClaimTypes.Role).Select(c => c.Value);
if (string.IsNullOrEmpty(tenantId))
{
Context.Abort();
return;
}
// Tenant isolation - all users in tenant
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
// User-specific notifications
await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{tenantId}:{userId}");
// Role-based groups
foreach (var role in roles ?? Enumerable.Empty<string>())
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"role:{tenantId}:{role}");
}
await base.OnConnectedAsync();
}
}
// Service for sending tenant-scoped notifications
public class TenantNotificationService
{
private readonly IHubContext<MultiTenantNotificationHub> _hubContext;
public TenantNotificationService(IHubContext<MultiTenantNotificationHub> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyTenant(string tenantId, object notification)
{
await _hubContext.Clients
.Group($"tenant:{tenantId}")
.SendAsync("ReceiveNotification", notification);
}
public async Task NotifyUser(string tenantId, string userId, object notification)
{
await _hubContext.Clients
.Group($"user:{tenantId}:{userId}")
.SendAsync("ReceiveNotification", notification);
}
public async Task NotifyRole(string tenantId, string role, object notification)
{
await _hubContext.Clients
.Group($"role:{tenantId}:{role}")
.SendAsync("ReceiveNotification", notification);
}
}
Service Workers and OS Notifications
Service Workers handle displaying OS-level notifications but cannot receive push messages directly from SignalR. The pattern is:
SignalR Message → Main Thread → postMessage → Service Worker → OS Notification
Service Worker (Display Only)
// service-worker.js
self.addEventListener('message', (event) => {
if (event.data?.type === 'SHOW_NOTIFICATION') {
const { title, body, icon, data } = event.data.payload;
self.registration.showNotification(title, {
body,
icon: icon || '/icon-192.png',
badge: '/badge-72.png',
data,
requireInteraction: data?.priority === 'high',
actions: [
{ action: 'view', title: 'View' },
{ action: 'dismiss', title: 'Dismiss' }
]
});
}
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'view' && event.notification.data?.url) {
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
Main Thread Integration
class NotificationManager {
private swRegistration: ServiceWorkerRegistration | null = null;
async initialize(): Promise<void> {
if ('serviceWorker' in navigator && 'Notification' in window) {
this.swRegistration = await navigator.serviceWorker.ready;
if (Notification.permission === 'default') {
await Notification.requestPermission();
}
}
}
// Called when SignalR receives a notification
async showOSNotification(notification: AppNotification): Promise<void> {
if (Notification.permission !== 'granted' || !this.swRegistration) {
return;
}
// Don't show OS notification if app is focused
if (document.hasFocus()) {
return;
}
this.swRegistration.active?.postMessage({
type: 'SHOW_NOTIFICATION',
payload: {
title: notification.title,
body: notification.body,
icon: notification.icon,
data: {
url: notification.actionUrl,
id: notification.id,
priority: notification.priority
}
}
});
}
}
Best Practices
1. Connection Management
// Always handle connection lifecycle
public override async Task OnConnectedAsync()
{
// Log connection
// Add to tracking
// Join appropriate groups
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Log disconnection (and reason)
// Clean up tracking
// Notify if needed
await base.OnDisconnectedAsync(exception);
}
2. Error Handling
public class NotificationHub : Hub
{
private readonly ILogger<NotificationHub> _logger;
public async Task SendMessage(MessageDto message)
{
try
{
// Validate input
if (string.IsNullOrEmpty(message.Content))
{
throw new HubException("Message content is required");
}
await Clients.Group(message.TargetGroup)
.SendAsync("ReceiveMessage", message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send message");
throw new HubException("Failed to send message");
}
}
}
3. Message Size and Batching
builder.Services.AddSignalR(options =>
{
// Limit message size to prevent abuse
options.MaximumReceiveMessageSize = 64 * 1024; // 64KB
// Stream large data instead
options.StreamBufferCapacity = 10;
});
// For large datasets, use streaming
public async IAsyncEnumerable<DataPoint> StreamData(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var point in _dataService.GetDataStream(cancellationToken))
{
yield return point;
}
}
4. Authentication Token Refresh
// Handle token expiration
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", {
accessTokenFactory: async () => {
const token = await authService.getAccessToken();
// Check if token is about to expire
if (authService.isTokenExpiringSoon()) {
await authService.refreshToken();
return await authService.getAccessToken();
}
return token;
}
})
.build();
5. Graceful Degradation
// Fallback strategy when WebSocket is not available
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/notifications", {
// Don't skip negotiation if you need fallbacks
skipNegotiation: false,
// Allow all transports
transport: signalR.HttpTransportType.WebSockets |
signalR.HttpTransportType.ServerSentEvents |
signalR.HttpTransportType.LongPolling
})
.build();