Index · Additional notes

2 min read
Mid-level5 min read
Rapid overview

Additional notes

SignalR vs Raw WebSockets vs SSE

FeatureSignalRRaw WebSocketsSSE
DirectionBi-directionalBi-directionalServer to client only
TransportAuto-selects (WS → SSE → LP)WebSocket onlyHTTP
Connection ManagementBuilt-in (reconnect, groups)ManualManual
ScalingRedis/Azure backplaneCustom implementationSimpler
Binary SupportYes (MessagePack)YesNo (text only)
Browser FallbackAutomaticNoneGood
Best ForEnterprise apps, notificationsGaming, custom protocolsSimple 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

  1. Client connects to any server instance (via load balancer)
  2. When server sends a message to a group, it publishes to Redis
  3. All server instances subscribed to Redis receive the message
  4. 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:

AspectRedis BackplaneSticky Sessions
FailoverSeamlessConnection lost
ScalingAny instanceLimited by stickiness
ComplexityRequires RedisSimpler infrastructure
CostAdditional componentNo 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();