Claude Skill

dotnet-signalr

Implement or review SignalR hubs, streaming, reconnection, transport, and real-time delivery patterns in ASP.NET Core applications.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download postpartum-genushyacinthus29-dotnet-skills-skills_dotnet-signalr-bfa4ebd.zip · 15 KB
Part of postpartum-genushyacinthus29/dotnet-skills — 80 skills

Install

skills CLI npx skills add https://github.com/Postpartum-genushyacinthus29/dotnet-skills/tree/main/skills/dotnet-signalr
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install postpartum-genushyacinthus29-dotnet-skills@llmmart
Git git clone https://github.com/Postpartum-genushyacinthus29/dotnet-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole postpartum-genushyacinthus29/dotnet-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SignalR

Trigger On

  • building chat, notification, collaboration, or live-update features
  • debugging hub lifetime, connection state, or transport issues
  • deciding whether SignalR or another transport better fits the scenario
  • implementing real-time broadcasting to groups of connected clients
  • scaling SignalR across multiple servers

Documentation

References

  • patterns.md - Detailed hub patterns, streaming, groups, presence, and advanced messaging techniques
  • anti-patterns.md - Common SignalR mistakes and how to avoid them

Workflow

  1. Use SignalR for broadcast-style or connection-oriented real-time features; do not force gRPC into hub-style fan-out scenarios.
  2. Model hub contracts intentionally and keep hub methods thin, delegating durable work elsewhere.
  3. Plan for reconnection, backpressure, auth, and fan-out costs instead of treating real-time messaging as stateless request/response.
  4. Use groups, presence, and connection metadata deliberately so scale-out behavior is understandable.
  5. If Native AOT or trimming is in play, validate supported protocols and serialization choices explicitly.
  6. Test connection behavior and failure modes, not just happy-path message delivery.

Hub Patterns

Strongly-Typed Hub (Recommended)

// Define the client interface
public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserJoined(string user);
    Task UserLeft(string user);
}

// Implement the strongly-typed hub
public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
    {
        // Compiler checks client method calls
        await Clients.All.ReceiveMessage(user, message);
    }

    public override async Task OnConnectedAsync()
    {
        await Clients.Others.UserJoined(Context.User?.Identity?.Name ?? "Anonymous");
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await Clients.Others.UserLeft(Context.User?.Identity?.Name ?? "Anonymous");
        await base.OnDisconnectedAsync(exception);
    }
}

Using Groups for Targeted Messaging

public class NotificationHub : Hub<INotificationClient>
{
    public async Task JoinGroup(string groupName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name);
    }

    public async Task LeaveGroup(string groupName)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
    }

    public async Task SendToGroup(string groupName, string message)
    {
        await Clients.Group(groupName).ReceiveNotification(message);
    }
}

Hub Method with Custom Object Parameters (API Versioning)

// Use custom objects to avoid breaking changes
public class SendMessageRequest
{
    public string Message { get; set; } = string.Empty;
    public string? Recipient { get; set; }  // Added later without breaking clients
    public int? Priority { get; set; }       // Added later without breaking clients
}

public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(SendMessageRequest request)
    {
        // Handle both old and new clients
        if (request.Recipient != null)
        {
            await Clients.User(request.Recipient).ReceiveMessage(request.Message);
        }
        else
        {
            await Clients.All.ReceiveMessage(request.Message);
        }
    }
}

Client Patterns

JavaScript Client with Automatic Reconnection

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/chatHub")
    .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Retry delays
    .configureLogging(signalR.LogLevel.Information)
    .build();

// Handle reconnection events
connection.onreconnecting(error => {
    console.log("Reconnecting...", error);
    updateUIForReconnecting();
});

connection.onreconnected(connectionId => {
    console.log("Reconnected with ID:", connectionId);
    // Rejoin groups - reconnection does not restore group membership
    rejoinGroups();
    updateUIForConnected();
});

connection.onclose(error => {
    console.log("Connection closed", error);
    updateUIForDisconnected();
});

async function start() {
    try {
        await connection.start();
        console.log("SignalR Connected");
    } catch (err) {
        console.log(err);
        setTimeout(start, 5000);
    }
}

start();

.NET Client with Reconnection

var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:5001/chatHub", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(GetAccessToken());
    })
    .WithAutomaticReconnect()
    .Build();

connection.Reconnecting += error =>
{
    _logger.LogWarning("Connection lost. Reconnecting: {Error}", error?.Message);
    return Task.CompletedTask;
};

connection.Reconnected += connectionId =>
{
    _logger.LogInformation("Reconnected with ID: {ConnectionId}", connectionId);
    // Rejoin groups after reconnection
    return RejoinGroupsAsync();
};

connection.Closed += async error =>
{
    _logger.LogError("Connection closed: {Error}", error?.Message);
    await Task.Delay(Random.Shared.Next(0, 5) * 1000);
    await connection.StartAsync();
};

await connection.StartAsync();

Server Configuration

Hub Registration with Authentication

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
    options.StreamBufferCapacity = 10;
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
})
.AddMessagePackProtocol(); // Binary protocol for performance

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // Read token from query string for WebSocket connections
                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;
            }
        };
    });

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapHub<ChatHub>("/hubs/chat");

Sending Messages from Outside a Hub

public class NotificationService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;

    public NotificationService(IHubContext<NotificationHub, INotificationClient> hubContext)
    {
        _hubContext = hubContext;
    }

    public async Task NotifyAllAsync(string message)
    {
        await _hubContext.Clients.All.ReceiveNotification(message);
    }

    public async Task NotifyUserAsync(string userId, string message)
    {
        await _hubContext.Clients.User(userId).ReceiveNotification(message);
    }

    public async Task NotifyGroupAsync(string groupName, string message)
    {
        await _hubContext.Clients.Group(groupName).ReceiveNotification(message);
    }
}

Scaling with Redis Backplane

builder.Services.AddSignalR()
    .AddStackExchangeRedis(connectionString, options =>
    {
        options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
    });

Anti-Patterns to Avoid

Anti-Pattern Why It's Bad Better Approach
Storing state in Hub properties Hub instances are created per method call Use IMemoryCache, database, or external store
Instantiating Hub directly Bypasses SignalR infrastructure Use IHubContext<THub> for external messaging
Not awaiting SendAsync calls Messages may not be sent before hub method completes Always await async hub calls
Adding method parameters without versioning Breaking change for existing clients Use custom object parameters
Ignoring reconnection group loss Clients lose group membership on reconnect Re-add to groups in OnConnectedAsync or client reconnect handler
Large payloads over SignalR Memory pressure, bandwidth issues Use REST/gRPC for bulk data, SignalR for notifications
Missing backplane in multi-server Messages only reach clients on same server Use Redis backplane or Azure SignalR Service
Exposing ORM entities directly May serialize sensitive data Use DTOs with explicit properties
Not validating incoming messages Security risk after initial auth Validate every hub method input

Best Practices

Connection Management

  1. Enable automatic reconnection with exponential backoff delays
  2. Handle group rejoining explicitly after reconnection (connection ID changes)
  3. Implement heartbeat monitoring on the client to detect stale connections
  4. Use sticky sessions when scaling across multiple servers (unless using Azure SignalR Service)

Performance

  1. Use MessagePack protocol for smaller message sizes and faster serialization
  2. Throttle high-frequency events like typing indicators or mouse movements
  3. Batch messages when possible instead of many small sends
  4. Set appropriate buffer sizes based on expected message throughput

Security

  1. Authenticate at connection time using JWT tokens via query string
  2. Authorize hub methods using [Authorize] attribute
  3. Validate all incoming messages even after authentication
  4. Use HTTPS for all SignalR connections

API Design

  1. Use strongly-typed hubs to catch client method name typos at compile time
  2. Use custom object parameters to enable backward-compatible API evolution
  3. Version hub names (e.g., ChatHubV2) for breaking changes
  4. Keep hub methods thin and delegate business logic to services

Observability

  1. Log connection events (connect, disconnect, reconnect)
  2. Track transport type used by each connection
  3. Monitor message delivery latency and failure rates
  4. Integrate with Application Insights or other APM tools

Deliver

  • clear hub contracts and connection behavior
  • real-time delivery that matches the product scenario
  • validation for reconnection and authorization flows
  • appropriate scale-out strategy for multi-server deployments

Validate

  • SignalR is the correct transport for the use case
  • hub methods remain orchestration-oriented
  • group and auth behavior are explicit and tested
  • reconnection and group membership are handled correctly
  • backplane is configured for multi-server scenarios
  • message validation is implemented in hub methods
Files (dotnet-skills)
  • references
    • anti-patterns.md 19.5 KB
      # SignalR Anti-Patterns
      
      This reference documents common SignalR mistakes and how to avoid them.
      
      ## Hub Instance Anti-Patterns
      
      ### Storing State in Hub Properties
      
      Hub instances are transient and created per invocation:
      
      ```csharp
      // BAD: State is lost between calls
      public class BadHub : Hub
      {
          private List<string> _messages = new(); // Created fresh every call
          private int _messageCount = 0;          // Always 0
      
          public Task SendMessage(string message)
          {
              _messages.Add(message);  // Lost immediately
              _messageCount++;         // Always 1
              return Task.CompletedTask;
          }
      }
      
      // GOOD: Use external state management
      public class GoodHub : Hub
      {
          private readonly IMessageStore _store;
          private readonly IMemoryCache _cache;
      
          public GoodHub(IMessageStore store, IMemoryCache cache)
          {
              _store = store;
              _cache = cache;
          }
      
          public async Task SendMessage(string message)
          {
              await _store.AddMessageAsync(message);
              _cache.Set("lastMessage", message);
          }
      }
      
      // GOOD: Use Context.Items for per-connection state
      public class ConnectionStateHub : Hub
      {
          public Task SetConnectionData(string key, object value)
          {
              Context.Items[key] = value;
              return Task.CompletedTask;
          }
      
          public object? GetConnectionData(string key)
          {
              return Context.Items.TryGetValue(key, out var value) ? value : null;
          }
      }
      ```
      
      ### Instantiating Hub Directly
      
      Never create hub instances manually:
      
      ```csharp
      // BAD: Bypasses SignalR infrastructure
      public class BadService
      {
          public async Task NotifyUsers()
          {
              var hub = new ChatHub(); // No context, no clients, no groups
              await hub.Clients.All.ReceiveMessage("test"); // NullReferenceException
          }
      }
      
      // GOOD: Use IHubContext<THub>
      public class GoodService
      {
          private readonly IHubContext<ChatHub, IChatClient> _hubContext;
      
          public GoodService(IHubContext<ChatHub, IChatClient> hubContext)
          {
              _hubContext = hubContext;
          }
      
          public async Task NotifyUsers()
          {
              await _hubContext.Clients.All.ReceiveMessage("test");
          }
      }
      ```
      
      ## Async Anti-Patterns
      
      ### Not Awaiting SendAsync Calls
      
      Fire-and-forget messaging loses errors and ordering:
      
      ```csharp
      // BAD: No await means method may complete before message is sent
      public class BadHub : Hub<IChatClient>
      {
          public Task SendMessage(string message)
          {
              Clients.All.ReceiveMessage(message); // Not awaited!
              return Task.CompletedTask;
          }
      }
      
      // BAD: Multiple unawaited calls have undefined ordering
      public class AlsoBadHub : Hub<IChatClient>
      {
          public Task NotifyAll()
          {
              Clients.All.Message1(); // Which arrives first?
              Clients.All.Message2(); // Unknown!
              return Task.CompletedTask;
          }
      }
      
      // GOOD: Always await async calls
      public class GoodHub : Hub<IChatClient>
      {
          public async Task SendMessage(string message)
          {
              await Clients.All.ReceiveMessage(message);
          }
      
          public async Task NotifyAll()
          {
              await Clients.All.Message1();
              await Clients.All.Message2(); // Guaranteed order
          }
      }
      ```
      
      ### Blocking Async Code
      
      Blocking calls cause thread pool starvation:
      
      ```csharp
      // BAD: Blocking on async
      public class BadHub : Hub
      {
          private readonly IDataService _dataService;
      
          public string GetData()
          {
              // Blocks thread pool thread
              return _dataService.GetDataAsync().Result;
          }
      
          public void SendBlocking()
          {
              // Potential deadlock
              Clients.All.SendAsync("Method").Wait();
          }
      }
      
      // GOOD: Async all the way
      public class GoodHub : Hub
      {
          private readonly IDataService _dataService;
      
          public async Task<string> GetData()
          {
              return await _dataService.GetDataAsync();
          }
      
          public async Task Send()
          {
              await Clients.All.SendAsync("Method");
          }
      }
      ```
      
      ## Connection Management Anti-Patterns
      
      ### Ignoring Group Loss on Reconnection
      
      Group membership is tied to connection ID, which changes on reconnect:
      
      ```csharp
      // BAD: Assumes groups persist across reconnection
      public class BadHub : Hub
      {
          public async Task JoinRoom(string roomId)
          {
              await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
              // Client reconnects with new connection ID = no longer in group
          }
      }
      
      // GOOD: Track groups and rejoin on connect
      public class GoodHub : Hub
      {
          private readonly IGroupMembershipStore _store;
      
          public GoodHub(IGroupMembershipStore store) => _store = store;
      
          public override async Task OnConnectedAsync()
          {
              var userId = Context.User!.GetUserId();
              var groups = await _store.GetUserGroupsAsync(userId);
      
              foreach (var group in groups)
              {
                  await Groups.AddToGroupAsync(Context.ConnectionId, group);
              }
      
              await base.OnConnectedAsync();
          }
      
          public async Task JoinRoom(string roomId)
          {
              var userId = Context.User!.GetUserId();
      
              // Persist membership
              await _store.AddUserToGroupAsync(userId, roomId);
      
              // Add current connection
              await Groups.AddToGroupAsync(Context.ConnectionId, roomId);
          }
      }
      ```
      
      Client-side handling is also required:
      
      ```javascript
      // BAD: No reconnection handling
      connection.start();
      
      // GOOD: Rejoin groups after reconnection
      connection.onreconnected(async (connectionId) => {
          console.log("Reconnected with ID:", connectionId);
          // Rejoin all rooms
          for (const roomId of joinedRooms) {
              await connection.invoke("JoinRoom", roomId);
          }
      });
      ```
      
      ### Not Handling Connection Lifecycle
      
      Ignoring connect/disconnect events loses cleanup opportunities:
      
      ```csharp
      // BAD: No lifecycle handling
      public class BadHub : Hub
      {
          public Task JoinGame(string gameId)
          {
              // What happens when they disconnect mid-game?
              return Groups.AddToGroupAsync(Context.ConnectionId, gameId);
          }
      }
      
      // GOOD: Handle lifecycle events
      public class GoodHub : Hub
      {
          private readonly IGameService _gameService;
      
          public GoodHub(IGameService gameService) => _gameService = gameService;
      
          public async Task JoinGame(string gameId)
          {
              Context.Items["GameId"] = gameId;
              await _gameService.AddPlayerAsync(gameId, Context.User!.GetUserId());
              await Groups.AddToGroupAsync(Context.ConnectionId, gameId);
          }
      
          public override async Task OnDisconnectedAsync(Exception? exception)
          {
              if (Context.Items.TryGetValue("GameId", out var gameIdObj) && gameIdObj is string gameId)
              {
                  await _gameService.RemovePlayerAsync(gameId, Context.User!.GetUserId());
                  await Clients.Group(gameId).PlayerLeft(Context.User!.GetUserId());
              }
              await base.OnDisconnectedAsync(exception);
          }
      }
      ```
      
      ## Security Anti-Patterns
      
      ### Not Validating Hub Method Inputs
      
      Authentication does not equal authorization or validation:
      
      ```csharp
      // BAD: No validation after auth
      [Authorize]
      public class BadHub : Hub
      {
          public async Task SendToGroup(string groupId, string message)
          {
              // Is user allowed in this group? Unknown!
              // Is message content safe? Unknown!
              await Clients.Group(groupId).ReceiveMessage(message);
          }
      
          public async Task GetUserData(string userId)
          {
              // Can caller access this user's data? Not checked!
              var data = await _userService.GetDataAsync(userId);
              await Clients.Caller.ReceiveData(data);
          }
      }
      
      // GOOD: Validate every hub method
      [Authorize]
      public class GoodHub : Hub
      {
          private readonly IAuthorizationService _authService;
          private readonly IValidator<SendMessageRequest> _validator;
      
          public async Task SendToGroup(string groupId, SendMessageRequest request)
          {
              // Check authorization
              var canSend = await _authService.CanSendToGroupAsync(Context.User!, groupId);
              if (!canSend)
              {
                  throw new HubException("Not authorized for this group");
              }
      
              // Validate content
              var validation = await _validator.ValidateAsync(request);
              if (!validation.IsValid)
              {
                  throw new HubException($"Invalid message: {validation.Errors.First().ErrorMessage}");
              }
      
              await Clients.Group(groupId).ReceiveMessage(request.Message);
          }
      
          public async Task GetUserData(string userId)
          {
              // Verify caller can access this data
              var callerId = Context.User!.GetUserId();
              if (userId != callerId && !Context.User.IsInRole("Admin"))
              {
                  throw new HubException("Not authorized to access this user's data");
              }
      
              var data = await _userService.GetDataAsync(userId);
              await Clients.Caller.ReceiveData(data);
          }
      }
      ```
      
      ### Exposing Internal Exceptions
      
      Internal errors leak implementation details:
      
      ```csharp
      // BAD: Exceptions leak to client
      public class BadHub : Hub
      {
          public async Task ProcessOrder(OrderRequest request)
          {
              // SqlException details visible to client
              // Stack traces visible to client
              await _orderService.ProcessAsync(request);
          }
      }
      
      // GOOD: Wrap exceptions
      public class GoodHub : Hub
      {
          private readonly ILogger<GoodHub> _logger;
      
          public async Task ProcessOrder(OrderRequest request)
          {
              try
              {
                  await _orderService.ProcessAsync(request);
              }
              catch (ValidationException ex)
              {
                  throw new HubException($"Validation error: {ex.Message}");
              }
              catch (BusinessRuleException ex)
              {
                  throw new HubException(ex.UserFriendlyMessage);
              }
              catch (Exception ex)
              {
                  _logger.LogError(ex, "Error processing order");
                  throw new HubException("An error occurred processing your order");
              }
          }
      }
      ```
      
      ## Scaling Anti-Patterns
      
      ### Missing Backplane for Multi-Server
      
      Without a backplane, messages only reach local connections:
      
      ```csharp
      // BAD: Single-server assumption
      builder.Services.AddSignalR();
      // Users on Server B never receive messages from Server A
      
      // GOOD: Use backplane for multi-server
      builder.Services.AddSignalR()
          .AddStackExchangeRedis(connectionString, options =>
          {
              options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
          });
      
      // OR: Use Azure SignalR Service
      builder.Services.AddSignalR()
          .AddAzureSignalR(connectionString);
      ```
      
      ### Storing Connection State In-Memory for Scale-Out
      
      In-memory state does not scale:
      
      ```csharp
      // BAD: In-memory state with scale-out
      public class BadPresenceTracker
      {
          // Only tracks connections on this server
          private readonly ConcurrentDictionary<string, string> _connections = new();
      }
      
      // GOOD: Distributed state
      public class GoodPresenceTracker
      {
          private readonly IDistributedCache _cache;
          // OR
          private readonly IConnectionMultiplexer _redis;
          // OR
          private readonly IDatabase _database;
      }
      ```
      
      ## Performance Anti-Patterns
      
      ### Large Payloads Over SignalR
      
      SignalR is optimized for small, frequent messages:
      
      ```csharp
      // BAD: Large file transfer over SignalR
      public class BadHub : Hub
      {
          public async Task UploadFile(byte[] fileData) // 50MB payload
          {
              await _fileService.SaveAsync(fileData);
          }
      
          public async Task<byte[]> DownloadFile(string fileId)
          {
              return await _fileService.GetAsync(fileId); // Returns 50MB
          }
      }
      
      // GOOD: Use SignalR for signaling, HTTP for bulk data
      public class GoodHub : Hub
      {
          public async Task<string> RequestUploadUrl()
          {
              // Return pre-signed URL for direct upload
              return await _blobService.GetUploadUrlAsync();
          }
      
          public async Task NotifyUploadComplete(string fileId)
          {
              await Clients.Others.FileUploaded(fileId);
          }
      
          public async Task<string> RequestDownloadUrl(string fileId)
          {
              return await _blobService.GetDownloadUrlAsync(fileId);
          }
      }
      ```
      
      ### No Throttling for High-Frequency Events
      
      Unthrottled events overwhelm clients and servers:
      
      ```csharp
      // BAD: Every keystroke sends a message
      public class BadHub : Hub
      {
          public async Task UserTyping()
          {
              await Clients.Others.UserIsTyping(Context.User!.Identity!.Name!);
          }
      }
      
      // GOOD: Throttle high-frequency events
      public class GoodHub : Hub
      {
          private readonly IThrottler _throttler;
      
          public async Task UserTyping()
          {
              var key = $"typing:{Context.ConnectionId}";
              if (await _throttler.ShouldThrottleAsync(key, TimeSpan.FromSeconds(2)))
              {
                  return; // Skip if sent within last 2 seconds
              }
      
              await Clients.Others.UserIsTyping(Context.User!.Identity!.Name!);
          }
      }
      ```
      
      ### Not Using Streaming for Large Result Sets
      
      Loading everything into memory wastes resources:
      
      ```csharp
      // BAD: Load all records into memory
      public class BadHub : Hub
      {
          public async Task<List<LogEntry>> GetLogs(DateTime from, DateTime to)
          {
              // Loads potentially millions of records
              return await _logService.GetAllLogsAsync(from, to);
          }
      }
      
      // GOOD: Stream results
      public class GoodHub : Hub
      {
          public async IAsyncEnumerable<LogEntry> StreamLogs(
              DateTime from,
              DateTime to,
              [EnumeratorCancellation] CancellationToken cancellationToken)
          {
              await foreach (var log in _logService.StreamLogsAsync(from, to, cancellationToken))
              {
                  yield return log;
              }
          }
      }
      ```
      
      ## API Design Anti-Patterns
      
      ### Using Dynamic Hubs
      
      Dynamic invocations lose compile-time safety:
      
      ```csharp
      // BAD: Dynamic client invocation
      public class BadHub : Hub
      {
          public async Task SendMessage(string message)
          {
              // Typos not caught at compile time
              await Clients.All.SendAsync("RecieveMessage", message); // Typo!
          }
      }
      
      // GOOD: Strongly-typed hub
      public interface IChatClient
      {
          Task ReceiveMessage(string message);
      }
      
      public class GoodHub : Hub<IChatClient>
      {
          public async Task SendMessage(string message)
          {
              // Compile-time checking
              await Clients.All.ReceiveMessage(message);
          }
      }
      ```
      
      ### Breaking API Changes
      
      Changing method signatures breaks existing clients:
      
      ```csharp
      // BAD: Breaking change
      public class HubV1 : Hub
      {
          // Original
          public Task SendMessage(string message) => ...;
      
          // Changed to add parameter - breaks existing clients!
          public Task SendMessage(string message, string category) => ...;
      }
      
      // GOOD: Use request objects for evolution
      public class SendMessageRequest
      {
          public string Message { get; set; } = "";
          public string? Category { get; set; }  // Added later, optional
          public int? Priority { get; set; }      // Added later, optional
      }
      
      public class GoodHub : Hub<IChatClient>
      {
          public async Task SendMessage(SendMessageRequest request)
          {
              // Handles both old and new clients
              var category = request.Category ?? "general";
              await ProcessMessageAsync(request.Message, category);
          }
      }
      ```
      
      ### Exposing ORM Entities
      
      Direct entity exposure leaks implementation and sensitive data:
      
      ```csharp
      // BAD: Exposing EF entities
      public class BadHub : Hub
      {
          public async Task<User> GetUser(string userId)
          {
              // May include navigation properties, password hashes, etc.
              return await _dbContext.Users.FindAsync(userId);
          }
      }
      
      // GOOD: Use DTOs
      public class GoodHub : Hub
      {
          public async Task<UserDto> GetUser(string userId)
          {
              var user = await _dbContext.Users.FindAsync(userId);
              return new UserDto
              {
                  Id = user.Id,
                  DisplayName = user.DisplayName,
                  AvatarUrl = user.AvatarUrl
                  // No password hash, no internal fields
              };
          }
      }
      ```
      
      ## Diagnostic Anti-Patterns
      
      ### No Logging for Connection Events
      
      Silent connections make debugging impossible:
      
      ```csharp
      // BAD: No visibility
      public class BadHub : Hub
      {
          // No logging, no metrics
      }
      
      // GOOD: Log important events
      public class GoodHub : Hub
      {
          private readonly ILogger<GoodHub> _logger;
      
          public GoodHub(ILogger<GoodHub> logger) => _logger = logger;
      
          public override async Task OnConnectedAsync()
          {
              _logger.LogInformation(
                  "Client connected: {ConnectionId}, User: {User}, Transport: {Transport}",
                  Context.ConnectionId,
                  Context.User?.Identity?.Name ?? "anonymous",
                  Context.Features.Get<IHttpTransportFeature>()?.TransportType);
      
              await base.OnConnectedAsync();
          }
      
          public override async Task OnDisconnectedAsync(Exception? exception)
          {
              if (exception != null)
              {
                  _logger.LogWarning(exception,
                      "Client disconnected with error: {ConnectionId}",
                      Context.ConnectionId);
              }
              else
              {
                  _logger.LogInformation(
                      "Client disconnected: {ConnectionId}",
                      Context.ConnectionId);
              }
      
              await base.OnDisconnectedAsync(exception);
          }
      }
      ```
      
      ### Not Monitoring Message Delivery
      
      Assuming messages always arrive:
      
      ```csharp
      // BAD: Fire and forget without tracking
      public class BadHub : Hub
      {
          public async Task Broadcast(string message)
          {
              await Clients.All.ReceiveMessage(message);
              // Did everyone get it? Unknown!
          }
      }
      
      // GOOD: Track delivery metrics
      public class GoodHub : Hub
      {
          private readonly IMetrics _metrics;
          private readonly ILogger<GoodHub> _logger;
      
          public async Task Broadcast(string message)
          {
              var stopwatch = Stopwatch.StartNew();
              try
              {
                  await Clients.All.ReceiveMessage(message);
                  _metrics.RecordBroadcast(stopwatch.ElapsedMilliseconds);
              }
              catch (Exception ex)
              {
                  _metrics.RecordBroadcastFailure();
                  _logger.LogError(ex, "Broadcast failed");
                  throw;
              }
          }
      }
      ```
      
      ## Testing Anti-Patterns
      
      ### Not Testing Reconnection Scenarios
      
      Only testing happy paths misses critical failures:
      
      ```csharp
      // BAD: Only test message sending
      [Fact]
      public async Task CanSendMessage()
      {
          await _connection.InvokeAsync("SendMessage", "hello");
          // What about reconnection? Disconnection?
      }
      
      // GOOD: Test connection lifecycle
      [Fact]
      public async Task ReconnectionRestoresGroupMembership()
      {
          await _connection.InvokeAsync("JoinRoom", "test-room");
      
          // Simulate network failure
          await _connection.StopAsync();
          await _connection.StartAsync();
      
          // Verify group membership restored
          var isInRoom = await _connection.InvokeAsync<bool>("IsInRoom", "test-room");
          Assert.True(isInRoom);
      }
      
      [Fact]
      public async Task DisconnectionCleansUpResources()
      {
          await _connection.InvokeAsync("JoinGame", "game-1");
          await _connection.StopAsync();
      
          // Verify cleanup occurred
          var game = await _gameService.GetGameAsync("game-1");
          Assert.DoesNotContain(_userId, game.Players);
      }
      ```
      
      ### Not Testing Under Load
      
      Performance issues only appear at scale:
      
      ```csharp
      // GOOD: Load test SignalR
      [Fact]
      public async Task HandlesHighMessageVolume()
      {
          var connections = new List<HubConnection>();
          var receivedCounts = new ConcurrentDictionary<string, int>();
      
          // Create many connections
          for (int i = 0; i < 100; i++)
          {
              var connection = CreateConnection();
              var connId = $"conn-{i}";
              receivedCounts[connId] = 0;
      
              connection.On<string>("ReceiveMessage", msg =>
              {
                  receivedCounts.AddOrUpdate(connId, 1, (_, c) => c + 1);
              });
      
              await connection.StartAsync();
              connections.Add(connection);
          }
      
          // Send many messages
          var sendTasks = Enumerable.Range(0, 1000)
              .Select(i => connections[i % connections.Count].InvokeAsync("SendMessage", $"msg-{i}"));
      
          await Task.WhenAll(sendTasks);
          await Task.Delay(5000); // Allow delivery
      
          // Verify delivery
          foreach (var count in receivedCounts.Values)
          {
              Assert.Equal(1000, count);
          }
      }
      ```
      
    • patterns.md 20.1 KB
      # SignalR Hub Patterns
      
      This reference provides detailed patterns for implementing SignalR hubs, streaming, groups, and connection management.
      
      ## Hub Design Patterns
      
      ### Hub per Feature
      
      Separate hubs by domain feature rather than creating one monolithic hub:
      
      ```csharp
      // Good: Feature-focused hubs
      app.MapHub<ChatHub>("/hubs/chat");
      app.MapHub<NotificationHub>("/hubs/notifications");
      app.MapHub<CollaborationHub>("/hubs/collaboration");
      app.MapHub<PresenceHub>("/hubs/presence");
      
      // Bad: One hub for everything
      app.MapHub<ApplicationHub>("/hubs/app"); // Too broad
      ```
      
      ### Hub Method Delegation Pattern
      
      Keep hub methods thin and delegate to services:
      
      ```csharp
      public class OrderHub : Hub<IOrderClient>
      {
          private readonly IOrderService _orderService;
          private readonly IValidator<PlaceOrderRequest> _validator;
          private readonly ILogger<OrderHub> _logger;
      
          public OrderHub(
              IOrderService orderService,
              IValidator<PlaceOrderRequest> validator,
              ILogger<OrderHub> logger)
          {
              _orderService = orderService;
              _validator = validator;
              _logger = logger;
          }
      
          public async Task<OrderResult> PlaceOrder(PlaceOrderRequest request)
          {
              // 1. Validate
              var validation = await _validator.ValidateAsync(request);
              if (!validation.IsValid)
              {
                  return OrderResult.Failed(validation.Errors);
              }
      
              // 2. Delegate to service
              var order = await _orderService.PlaceOrderAsync(
                  request,
                  Context.User!.GetUserId());
      
              // 3. Broadcast (orchestration only)
              await Clients.Group($"order-watchers-{order.CustomerId}")
                  .OrderPlaced(order.ToDto());
      
              return OrderResult.Success(order.Id);
          }
      }
      ```
      
      ### Connection Context Pattern
      
      Use connection items for per-connection state:
      
      ```csharp
      public class GameHub : Hub<IGameClient>
      {
          public async Task JoinGame(string gameId)
          {
              // Store per-connection state
              Context.Items["GameId"] = gameId;
              Context.Items["JoinedAt"] = DateTime.UtcNow;
      
              await Groups.AddToGroupAsync(Context.ConnectionId, $"game-{gameId}");
              await Clients.Group($"game-{gameId}").PlayerJoined(Context.User!.Identity!.Name!);
          }
      
          public async Task MakeMove(MoveRequest move)
          {
              // Retrieve per-connection state
              var gameId = Context.Items["GameId"] as string
                  ?? throw new HubException("Not in a game");
      
              // Process move...
          }
      
          public override async Task OnDisconnectedAsync(Exception? exception)
          {
              if (Context.Items.TryGetValue("GameId", out var gameIdObj) && gameIdObj is string gameId)
              {
                  await Clients.Group($"game-{gameId}").PlayerLeft(Context.User!.Identity!.Name!);
              }
              await base.OnDisconnectedAsync(exception);
          }
      }
      ```
      
      ## Streaming Patterns
      
      ### Server-to-Client Streaming
      
      Use `IAsyncEnumerable<T>` for server-to-client streams:
      
      ```csharp
      public class DataHub : Hub<IDataClient>
      {
          private readonly IDataService _dataService;
      
          public DataHub(IDataService dataService) => _dataService = dataService;
      
          // Client calls: connection.stream("StreamData", query)
          public async IAsyncEnumerable<DataItem> StreamData(
              DataQuery query,
              [EnumeratorCancellation] CancellationToken cancellationToken)
          {
              await foreach (var item in _dataService.GetDataStreamAsync(query, cancellationToken))
              {
                  // Check cancellation between yields
                  cancellationToken.ThrowIfCancellationRequested();
                  yield return item;
              }
          }
      
          // Alternative with ChannelReader for more control
          public ChannelReader<StockQuote> StreamStockQuotes(
              string[] symbols,
              CancellationToken cancellationToken)
          {
              var channel = Channel.CreateUnbounded<StockQuote>();
      
              _ = WriteQuotesToChannelAsync(channel.Writer, symbols, cancellationToken);
      
              return channel.Reader;
          }
      
          private async Task WriteQuotesToChannelAsync(
              ChannelWriter<StockQuote> writer,
              string[] symbols,
              CancellationToken cancellationToken)
          {
              try
              {
                  while (!cancellationToken.IsCancellationRequested)
                  {
                      foreach (var symbol in symbols)
                      {
                          var quote = await GetQuoteAsync(symbol);
                          await writer.WriteAsync(quote, cancellationToken);
                      }
                      await Task.Delay(1000, cancellationToken);
                  }
              }
              catch (OperationCanceledException)
              {
                  // Expected when client disconnects
              }
              finally
              {
                  writer.Complete();
              }
          }
      }
      ```
      
      ### Client-to-Server Streaming
      
      Accept `ChannelReader<T>` or `IAsyncEnumerable<T>` for client uploads:
      
      ```csharp
      public class UploadHub : Hub<IUploadClient>
      {
          public async Task UploadChunks(
              string fileName,
              ChannelReader<byte[]> stream)
          {
              var totalBytes = 0L;
      
              await foreach (var chunk in stream.ReadAllAsync(Context.ConnectionAborted))
              {
                  // Process each chunk
                  await ProcessChunkAsync(fileName, chunk);
                  totalBytes += chunk.Length;
      
                  // Report progress back to client
                  await Clients.Caller.UploadProgress(totalBytes);
              }
      
              await Clients.Caller.UploadComplete(fileName, totalBytes);
          }
      
          // IAsyncEnumerable variant
          public async Task StreamMessages(IAsyncEnumerable<ChatMessage> stream)
          {
              await foreach (var message in stream)
              {
                  // Validate each message
                  if (string.IsNullOrWhiteSpace(message.Content))
                      continue;
      
                  // Broadcast to group
                  await Clients.Group(message.RoomId).ReceiveMessage(message);
              }
          }
      }
      ```
      
      ### Bidirectional Streaming
      
      Combine both patterns for full-duplex streams:
      
      ```csharp
      public class CollaborationHub : Hub<ICollaborationClient>
      {
          public async IAsyncEnumerable<DocumentChange> Collaborate(
              string documentId,
              IAsyncEnumerable<DocumentEdit> edits,
              [EnumeratorCancellation] CancellationToken cancellationToken)
          {
              // Join the document group to receive others' changes
              await Groups.AddToGroupAsync(Context.ConnectionId, $"doc-{documentId}");
      
              // Start a background task to process incoming edits
              var editProcessor = ProcessEditsAsync(documentId, edits, cancellationToken);
      
              // Stream out all changes from other users
              var changeChannel = GetChangeChannel(documentId);
      
              await foreach (var change in changeChannel.ReadAllAsync(cancellationToken))
              {
                  // Skip changes from this connection
                  if (change.ConnectionId != Context.ConnectionId)
                  {
                      yield return change;
                  }
              }
      
              await editProcessor;
          }
      }
      ```
      
      ## Group Patterns
      
      ### Hierarchical Groups
      
      Model group membership hierarchically:
      
      ```csharp
      public class OrganizationHub : Hub<IOrgClient>
      {
          public async Task JoinOrganization(string orgId)
          {
              // Hierarchical group structure
              await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}");
          }
      
          public async Task JoinTeam(string orgId, string teamId)
          {
              // More specific group
              await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}-team-{teamId}");
          }
      
          public async Task JoinProject(string orgId, string teamId, string projectId)
          {
              // Most specific group
              await Groups.AddToGroupAsync(Context.ConnectionId, $"org-{orgId}-team-{teamId}-project-{projectId}");
          }
      
          public async Task NotifyOrganization(string orgId, Notification notification)
          {
              // Reaches everyone in the org
              await Clients.Group($"org-{orgId}").ReceiveNotification(notification);
          }
      
          public async Task NotifyTeam(string orgId, string teamId, Notification notification)
          {
              // Reaches only team members
              await Clients.Group($"org-{orgId}-team-{teamId}").ReceiveNotification(notification);
          }
      }
      ```
      
      ### Group Membership Tracking
      
      Track group membership for presence features:
      
      ```csharp
      public class PresenceHub : Hub<IPresenceClient>
      {
          private readonly IGroupMembershipService _membership;
      
          public PresenceHub(IGroupMembershipService membership) => _membership = membership;
      
          public async Task JoinRoom(string roomId)
          {
              var userId = Context.User!.GetUserId();
              var connectionId = Context.ConnectionId;
      
              // Track in persistent store
              await _membership.AddMemberAsync(roomId, userId, connectionId);
      
              // Add to SignalR group
              await Groups.AddToGroupAsync(connectionId, roomId);
      
              // Get current members and notify
              var members = await _membership.GetMembersAsync(roomId);
              await Clients.Caller.RoomMembers(members);
              await Clients.OthersInGroup(roomId).UserJoined(userId);
          }
      
          public override async Task OnDisconnectedAsync(Exception? exception)
          {
              var userId = Context.User!.GetUserId();
              var connectionId = Context.ConnectionId;
      
              // Find and clean up all rooms this connection was in
              var rooms = await _membership.GetRoomsForConnectionAsync(connectionId);
              foreach (var roomId in rooms)
              {
                  await _membership.RemoveMemberAsync(roomId, connectionId);
      
                  // Check if user has other connections in this room
                  var hasOtherConnections = await _membership.UserHasConnectionsInRoomAsync(roomId, userId);
                  if (!hasOtherConnections)
                  {
                      await Clients.Group(roomId).UserLeft(userId);
                  }
              }
      
              await base.OnDisconnectedAsync(exception);
          }
      }
      ```
      
      ### Excluding Connections from Group Sends
      
      Use exclusion lists for targeted broadcasts:
      
      ```csharp
      public class BroadcastHub : Hub<IBroadcastClient>
      {
          public async Task SendToGroupExcept(
              string groupName,
              string message,
              string[] excludeConnectionIds)
          {
              await Clients
                  .GroupExcept(groupName, excludeConnectionIds)
                  .ReceiveMessage(message);
          }
      
          public async Task SendToAllExceptCaller(string message)
          {
              // Built-in exclusion of caller
              await Clients.Others.ReceiveMessage(message);
          }
      
          public async Task SendToGroupExceptCaller(string groupName, string message)
          {
              // Exclude caller from group send
              await Clients.OthersInGroup(groupName).ReceiveMessage(message);
          }
      }
      ```
      
      ## User-Based Targeting
      
      ### Multi-Connection Users
      
      Handle users with multiple connections:
      
      ```csharp
      public class UserHub : Hub<IUserClient>
      {
          public async Task SendToUser(string userId, Message message)
          {
              // Reaches all connections for this user
              await Clients.User(userId).ReceiveMessage(message);
          }
      
          public async Task SendToUsers(string[] userIds, Message message)
          {
              await Clients.Users(userIds).ReceiveMessage(message);
          }
      }
      ```
      
      ### Custom User ID Provider
      
      Map connections to custom user identifiers:
      
      ```csharp
      public class TenantUserIdProvider : IUserIdProvider
      {
          public string? GetUserId(HubConnectionContext connection)
          {
              var tenantId = connection.User?.FindFirst("tenant_id")?.Value;
              var userId = connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
      
              if (tenantId != null && userId != null)
              {
                  // Namespace user IDs by tenant for multi-tenant apps
                  return $"{tenantId}:{userId}";
              }
      
              return userId;
          }
      }
      
      // Registration
      builder.Services.AddSingleton<IUserIdProvider, TenantUserIdProvider>();
      ```
      
      ## Presence Patterns
      
      ### Connection Counting
      
      Track user presence across connections:
      
      ```csharp
      public class PresenceTracker
      {
          private readonly ConcurrentDictionary<string, HashSet<string>> _userConnections = new();
      
          public bool AddConnection(string userId, string connectionId)
          {
              var connections = _userConnections.GetOrAdd(userId, _ => new HashSet<string>());
              lock (connections)
              {
                  var wasEmpty = connections.Count == 0;
                  connections.Add(connectionId);
                  return wasEmpty; // True if this is the first connection
              }
          }
      
          public bool RemoveConnection(string userId, string connectionId)
          {
              if (_userConnections.TryGetValue(userId, out var connections))
              {
                  lock (connections)
                  {
                      connections.Remove(connectionId);
                      if (connections.Count == 0)
                      {
                          _userConnections.TryRemove(userId, out _);
                          return true; // User is now offline
                      }
                  }
              }
              return false;
          }
      
          public string[] GetOnlineUsers() => _userConnections.Keys.ToArray();
      }
      ```
      
      ### Presence with Redis (Scale-Out)
      
      Distributed presence for multi-server deployments:
      
      ```csharp
      public class RedisPresenceService : IPresenceService
      {
          private readonly IConnectionMultiplexer _redis;
          private readonly string _serverInstance;
      
          public RedisPresenceService(IConnectionMultiplexer redis)
          {
              _redis = redis;
              _serverInstance = $"{Environment.MachineName}-{Process.GetCurrentProcess().Id}";
          }
      
          public async Task<bool> UserConnectedAsync(string userId, string connectionId)
          {
              var db = _redis.GetDatabase();
              var key = $"presence:{userId}";
      
              var wasEmpty = !(await db.KeyExistsAsync(key));
      
              await db.HashSetAsync(key, connectionId, _serverInstance);
              await db.KeyExpireAsync(key, TimeSpan.FromMinutes(30));
      
              return wasEmpty;
          }
      
          public async Task<bool> UserDisconnectedAsync(string userId, string connectionId)
          {
              var db = _redis.GetDatabase();
              var key = $"presence:{userId}";
      
              await db.HashDeleteAsync(key, connectionId);
              var remaining = await db.HashLengthAsync(key);
      
              return remaining == 0;
          }
      
          public async Task<string[]> GetOnlineUsersAsync()
          {
              var server = _redis.GetServer(_redis.GetEndPoints().First());
              var keys = server.Keys(pattern: "presence:*");
      
              return keys.Select(k => k.ToString().Replace("presence:", "")).ToArray();
          }
      }
      ```
      
      ## Request-Response Pattern
      
      ### Hub Methods with Return Values
      
      Return results directly from hub methods:
      
      ```csharp
      public class QueryHub : Hub<IQueryClient>
      {
          private readonly IQueryService _queryService;
      
          public QueryHub(IQueryService queryService) => _queryService = queryService;
      
          public async Task<SearchResult> Search(SearchRequest request)
          {
              // Validate
              if (string.IsNullOrEmpty(request.Query))
              {
                  throw new HubException("Query is required");
              }
      
              // Execute and return
              return await _queryService.SearchAsync(request, Context.ConnectionAborted);
          }
      
          public async Task<PagedResult<Item>> GetItems(int page, int pageSize)
          {
              if (pageSize > 100)
              {
                  throw new HubException("Page size cannot exceed 100");
              }
      
              return await _queryService.GetItemsAsync(page, pageSize);
          }
      }
      ```
      
      ### Error Handling
      
      Use `HubException` for client-visible errors:
      
      ```csharp
      public class RobustHub : Hub<IRobustClient>
      {
          public async Task<OperationResult> PerformOperation(OperationRequest request)
          {
              try
              {
                  // Business logic...
                  return OperationResult.Success();
              }
              catch (ValidationException ex)
              {
                  // Client sees this message
                  throw new HubException($"Validation failed: {ex.Message}");
              }
              catch (NotFoundException ex)
              {
                  throw new HubException($"Not found: {ex.Message}");
              }
              catch (Exception ex)
              {
                  _logger.LogError(ex, "Unexpected error in PerformOperation");
                  // Generic message for unexpected errors
                  throw new HubException("An unexpected error occurred");
              }
          }
      }
      ```
      
      ## Acknowledgment Pattern
      
      ### Message Acknowledgments
      
      Track message delivery acknowledgments:
      
      ```csharp
      public interface IReliableClient
      {
          Task ReceiveMessage(string messageId, Message message);
          Task MessageAcknowledged(string messageId);
      }
      
      public class ReliableHub : Hub<IReliableClient>
      {
          private readonly IPendingMessageStore _pendingStore;
      
          public ReliableHub(IPendingMessageStore pendingStore) => _pendingStore = pendingStore;
      
          public async Task SendReliable(string recipientId, Message message)
          {
              var messageId = Guid.NewGuid().ToString();
      
              // Store pending message
              await _pendingStore.StorePendingAsync(messageId, recipientId, message);
      
              // Attempt delivery
              await Clients.User(recipientId).ReceiveMessage(messageId, message);
      
              // Notify sender
              await Clients.Caller.MessageAcknowledged(messageId);
          }
      
          public async Task AcknowledgeMessage(string messageId)
          {
              // Mark as delivered
              await _pendingStore.MarkDeliveredAsync(messageId);
          }
      
          // Background job retries unacknowledged messages
      }
      ```
      
      ## Batching Pattern
      
      ### Message Batching for High Throughput
      
      Batch messages to reduce overhead:
      
      ```csharp
      public class BatchingHub : Hub<IBatchingClient>
      {
          private readonly IBatchService _batchService;
      
          public BatchingHub(IBatchService batchService) => _batchService = batchService;
      
          public async Task StartBatchedUpdates(string subscriptionId)
          {
              var channel = Channel.CreateBounded<Update>(new BoundedChannelOptions(1000)
              {
                  FullMode = BoundedChannelFullMode.DropOldest
              });
      
              // Register this connection for updates
              _batchService.RegisterSubscription(subscriptionId, Context.ConnectionId, channel.Writer);
      
              // Background batching loop
              _ = Task.Run(async () =>
              {
                  var batch = new List<Update>();
                  var batchTimeout = TimeSpan.FromMilliseconds(100);
      
                  while (!Context.ConnectionAborted.IsCancellationRequested)
                  {
                      batch.Clear();
      
                      // Collect items for up to 100ms or 50 items
                      using var cts = CancellationTokenSource.CreateLinkedTokenSource(Context.ConnectionAborted);
                      cts.CancelAfter(batchTimeout);
      
                      try
                      {
                          while (batch.Count < 50 && await channel.Reader.WaitToReadAsync(cts.Token))
                          {
                              if (channel.Reader.TryRead(out var item))
                              {
                                  batch.Add(item);
                              }
                          }
                      }
                      catch (OperationCanceledException)
                      {
                          // Timeout or disconnect - send what we have
                      }
      
                      if (batch.Count > 0)
                      {
                          await Clients.Caller.ReceiveBatch(batch.ToArray());
                      }
                  }
              });
          }
      }
      ```
      
      ## Throttling Pattern
      
      ### Rate Limiting Hub Methods
      
      Implement per-connection rate limiting:
      
      ```csharp
      public class ThrottledHub : Hub<IThrottledClient>
      {
          private readonly IRateLimiter _rateLimiter;
      
          public ThrottledHub(IRateLimiter rateLimiter) => _rateLimiter = rateLimiter;
      
          public async Task SendMessage(string message)
          {
              var key = $"hub:{Context.ConnectionId}:sendMessage";
      
              if (!await _rateLimiter.TryAcquireAsync(key, maxRequests: 10, window: TimeSpan.FromSeconds(1)))
              {
                  throw new HubException("Rate limit exceeded. Please slow down.");
              }
      
              await Clients.All.ReceiveMessage(Context.User!.Identity!.Name!, message);
          }
      }
      
      // Using System.Threading.RateLimiting
      public class SlidingWindowRateLimiter : IRateLimiter
      {
          private readonly ConcurrentDictionary<string, RateLimiter> _limiters = new();
      
          public async ValueTask<bool> TryAcquireAsync(string key, int maxRequests, TimeSpan window)
          {
              var limiter = _limiters.GetOrAdd(key, _ =>
                  new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
                  {
                      PermitLimit = maxRequests,
                      Window = window,
                      SegmentsPerWindow = 4,
                      AutoReplenishment = true
                  }));
      
              using var lease = await limiter.AcquireAsync();
              return lease.IsAcquired;
          }
      }
      ```
      
  • SKILL.md 11.7 KB
    ---
    name: dotnet-signalr
    version: "1.0.0"
    category: "Web"
    description: "Implement or review SignalR hubs, streaming, reconnection, transport, and real-time delivery patterns in ASP.NET Core applications."
    compatibility: "Requires ASP.NET Core SignalR server or client code."
    ---
    
    # SignalR
    
    ## Trigger On
    
    - building chat, notification, collaboration, or live-update features
    - debugging hub lifetime, connection state, or transport issues
    - deciding whether SignalR or another transport better fits the scenario
    - implementing real-time broadcasting to groups of connected clients
    - scaling SignalR across multiple servers
    
    ## Documentation
    
    - [ASP.NET Core SignalR Overview](https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction?view=aspnetcore-10.0)
    - [SignalR Hubs](https://learn.microsoft.com/en-us/aspnet/core/signalr/hubs?view=aspnetcore-10.0)
    - [SignalR API Design Considerations](https://learn.microsoft.com/en-us/aspnet/core/signalr/api-design?view=aspnetcore-10.0)
    - [SignalR Production Hosting and Scaling](https://learn.microsoft.com/en-us/aspnet/core/signalr/scale?view=aspnetcore-10.0)
    - [SignalR Configuration](https://learn.microsoft.com/en-us/aspnet/core/signalr/configuration?view=aspnetcore-10.0)
    
    ### References
    
    - [patterns.md](references/patterns.md) - Detailed hub patterns, streaming, groups, presence, and advanced messaging techniques
    - [anti-patterns.md](references/anti-patterns.md) - Common SignalR mistakes and how to avoid them
    
    ## Workflow
    
    1. Use SignalR for broadcast-style or connection-oriented real-time features; do not force gRPC into hub-style fan-out scenarios.
    2. Model hub contracts intentionally and keep hub methods thin, delegating durable work elsewhere.
    3. Plan for reconnection, backpressure, auth, and fan-out costs instead of treating real-time messaging as stateless request/response.
    4. Use groups, presence, and connection metadata deliberately so scale-out behavior is understandable.
    5. If Native AOT or trimming is in play, validate supported protocols and serialization choices explicitly.
    6. Test connection behavior and failure modes, not just happy-path message delivery.
    
    ## Hub Patterns
    
    ### Strongly-Typed Hub (Recommended)
    ```csharp
    // Define the client interface
    public interface IChatClient
    {
        Task ReceiveMessage(string user, string message);
        Task UserJoined(string user);
        Task UserLeft(string user);
    }
    
    // Implement the strongly-typed hub
    public class ChatHub : Hub<IChatClient>
    {
        public async Task SendMessage(string user, string message)
        {
            // Compiler checks client method calls
            await Clients.All.ReceiveMessage(user, message);
        }
    
        public override async Task OnConnectedAsync()
        {
            await Clients.Others.UserJoined(Context.User?.Identity?.Name ?? "Anonymous");
            await base.OnConnectedAsync();
        }
    
        public override async Task OnDisconnectedAsync(Exception? exception)
        {
            await Clients.Others.UserLeft(Context.User?.Identity?.Name ?? "Anonymous");
            await base.OnDisconnectedAsync(exception);
        }
    }
    ```
    
    ### Using Groups for Targeted Messaging
    ```csharp
    public class NotificationHub : Hub<INotificationClient>
    {
        public async Task JoinGroup(string groupName)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
            await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name);
        }
    
        public async Task LeaveGroup(string groupName)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
        }
    
        public async Task SendToGroup(string groupName, string message)
        {
            await Clients.Group(groupName).ReceiveNotification(message);
        }
    }
    ```
    
    ### Hub Method with Custom Object Parameters (API Versioning)
    ```csharp
    // Use custom objects to avoid breaking changes
    public class SendMessageRequest
    {
        public string Message { get; set; } = string.Empty;
        public string? Recipient { get; set; }  // Added later without breaking clients
        public int? Priority { get; set; }       // Added later without breaking clients
    }
    
    public class ChatHub : Hub<IChatClient>
    {
        public async Task SendMessage(SendMessageRequest request)
        {
            // Handle both old and new clients
            if (request.Recipient != null)
            {
                await Clients.User(request.Recipient).ReceiveMessage(request.Message);
            }
            else
            {
                await Clients.All.ReceiveMessage(request.Message);
            }
        }
    }
    ```
    
    ## Client Patterns
    
    ### JavaScript Client with Automatic Reconnection
    ```javascript
    const connection = new signalR.HubConnectionBuilder()
        .withUrl("/chatHub")
        .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Retry delays
        .configureLogging(signalR.LogLevel.Information)
        .build();
    
    // Handle reconnection events
    connection.onreconnecting(error => {
        console.log("Reconnecting...", error);
        updateUIForReconnecting();
    });
    
    connection.onreconnected(connectionId => {
        console.log("Reconnected with ID:", connectionId);
        // Rejoin groups - reconnection does not restore group membership
        rejoinGroups();
        updateUIForConnected();
    });
    
    connection.onclose(error => {
        console.log("Connection closed", error);
        updateUIForDisconnected();
    });
    
    async function start() {
        try {
            await connection.start();
            console.log("SignalR Connected");
        } catch (err) {
            console.log(err);
            setTimeout(start, 5000);
        }
    }
    
    start();
    ```
    
    ### .NET Client with Reconnection
    ```csharp
    var connection = new HubConnectionBuilder()
        .WithUrl("https://localhost:5001/chatHub", options =>
        {
            options.AccessTokenProvider = () => Task.FromResult(GetAccessToken());
        })
        .WithAutomaticReconnect()
        .Build();
    
    connection.Reconnecting += error =>
    {
        _logger.LogWarning("Connection lost. Reconnecting: {Error}", error?.Message);
        return Task.CompletedTask;
    };
    
    connection.Reconnected += connectionId =>
    {
        _logger.LogInformation("Reconnected with ID: {ConnectionId}", connectionId);
        // Rejoin groups after reconnection
        return RejoinGroupsAsync();
    };
    
    connection.Closed += async error =>
    {
        _logger.LogError("Connection closed: {Error}", error?.Message);
        await Task.Delay(Random.Shared.Next(0, 5) * 1000);
        await connection.StartAsync();
    };
    
    await connection.StartAsync();
    ```
    
    ## Server Configuration
    
    ### Hub Registration with Authentication
    ```csharp
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddSignalR(options =>
    {
        options.EnableDetailedErrors = builder.Environment.IsDevelopment();
        options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
        options.StreamBufferCapacity = 10;
        options.KeepAliveInterval = TimeSpan.FromSeconds(15);
        options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    })
    .AddMessagePackProtocol(); // Binary protocol for performance
    
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    // Read token from query string for WebSocket connections
                    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;
                }
            };
        });
    
    var app = builder.Build();
    
    app.UseAuthentication();
    app.UseAuthorization();
    
    app.MapHub<ChatHub>("/hubs/chat");
    ```
    
    ### Sending Messages from Outside a Hub
    ```csharp
    public class NotificationService
    {
        private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;
    
        public NotificationService(IHubContext<NotificationHub, INotificationClient> hubContext)
        {
            _hubContext = hubContext;
        }
    
        public async Task NotifyAllAsync(string message)
        {
            await _hubContext.Clients.All.ReceiveNotification(message);
        }
    
        public async Task NotifyUserAsync(string userId, string message)
        {
            await _hubContext.Clients.User(userId).ReceiveNotification(message);
        }
    
        public async Task NotifyGroupAsync(string groupName, string message)
        {
            await _hubContext.Clients.Group(groupName).ReceiveNotification(message);
        }
    }
    ```
    
    ## Scaling with Redis Backplane
    
    ```csharp
    builder.Services.AddSignalR()
        .AddStackExchangeRedis(connectionString, options =>
        {
            options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
        });
    ```
    
    ## Anti-Patterns to Avoid
    
    | Anti-Pattern | Why It's Bad | Better Approach |
    |--------------|--------------|-----------------|
    | Storing state in Hub properties | Hub instances are created per method call | Use `IMemoryCache`, database, or external store |
    | Instantiating Hub directly | Bypasses SignalR infrastructure | Use `IHubContext<THub>` for external messaging |
    | Not awaiting `SendAsync` calls | Messages may not be sent before hub method completes | Always `await` async hub calls |
    | Adding method parameters without versioning | Breaking change for existing clients | Use custom object parameters |
    | Ignoring reconnection group loss | Clients lose group membership on reconnect | Re-add to groups in `OnConnectedAsync` or client reconnect handler |
    | Large payloads over SignalR | Memory pressure, bandwidth issues | Use REST/gRPC for bulk data, SignalR for notifications |
    | Missing backplane in multi-server | Messages only reach clients on same server | Use Redis backplane or Azure SignalR Service |
    | Exposing ORM entities directly | May serialize sensitive data | Use DTOs with explicit properties |
    | Not validating incoming messages | Security risk after initial auth | Validate every hub method input |
    
    ## Best Practices
    
    ### Connection Management
    1. **Enable automatic reconnection** with exponential backoff delays
    2. **Handle group rejoining** explicitly after reconnection (connection ID changes)
    3. **Implement heartbeat monitoring** on the client to detect stale connections
    4. **Use sticky sessions** when scaling across multiple servers (unless using Azure SignalR Service)
    
    ### Performance
    1. **Use MessagePack protocol** for smaller message sizes and faster serialization
    2. **Throttle high-frequency events** like typing indicators or mouse movements
    3. **Batch messages** when possible instead of many small sends
    4. **Set appropriate buffer sizes** based on expected message throughput
    
    ### Security
    1. **Authenticate at connection time** using JWT tokens via query string
    2. **Authorize hub methods** using `[Authorize]` attribute
    3. **Validate all incoming messages** even after authentication
    4. **Use HTTPS** for all SignalR connections
    
    ### API Design
    1. **Use strongly-typed hubs** to catch client method name typos at compile time
    2. **Use custom object parameters** to enable backward-compatible API evolution
    3. **Version hub names** (e.g., `ChatHubV2`) for breaking changes
    4. **Keep hub methods thin** and delegate business logic to services
    
    ### Observability
    1. **Log connection events** (connect, disconnect, reconnect)
    2. **Track transport type** used by each connection
    3. **Monitor message delivery** latency and failure rates
    4. **Integrate with Application Insights** or other APM tools
    
    ## Deliver
    
    - clear hub contracts and connection behavior
    - real-time delivery that matches the product scenario
    - validation for reconnection and authorization flows
    - appropriate scale-out strategy for multi-server deployments
    
    ## Validate
    
    - SignalR is the correct transport for the use case
    - hub methods remain orchestration-oriented
    - group and auth behavior are explicit and tested
    - reconnection and group membership are handled correctly
    - backplane is configured for multi-server scenarios
    - message validation is implemented in hub methods
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related