Real-time Apps with SignalR in .NET

12 min readMarch 9, 2026
SignalR .NETReal-time .NETWebSocket .NETSignalR HubSignalR RedisSignalR scalingReal-time dashboardSignalR authentication

# Real-time Apps with SignalR in .NET

Building real-time features -- live dashboards, chat systems, collaborative editors, notification feeds -- requires a fundamentally different communication model than traditional request-response. SignalR abstracts the complexity of persistent connections, transport negotiation, and reconnection logic into a clean programming model. In real-time dashboards I've built for monitoring platforms, SignalR consistently proved to be the fastest path from idea to production-ready bidirectional communication.

Why SignalR Over Raw WebSockets

SignalR is not just a WebSocket wrapper. It handles transport fallback (WebSockets, Server-Sent Events, Long Polling), automatic reconnection, connection lifecycle management, and message serialization. Writing all of this from scratch with raw WebSockets is possible but rarely worth the effort for business applications.

The key advantages:

  • Automatic transport negotiation based on client and server capabilities
  • Built-in connection grouping and user tracking
  • Strongly-typed hub contracts for compile-time safety
  • Native integration with ASP.NET Core authentication and authorization
  • Scaling support via Redis, Azure SignalR Service, or custom backplanes
  • Setting Up a SignalR Hub

    The Hub is the server-side entry point for all client communication. Each public method on the hub can be invoked by connected clients.

    Basic Hub

    csharp
    public class NotificationHub : Hub
    {
        private readonly ILogger<NotificationHub> _logger;
    
        public NotificationHub(ILogger<NotificationHub> logger)
        {
            _logger = logger;
        }
    
        public async Task SendNotification(string user, string message)
        {
            _logger.LogInformation(
                class="code-string">"Notification from {ConnectionId} to {User}",
                Context.ConnectionId, user);
    
            await Clients.User(user).SendAsync(class="code-string">"ReceiveNotification", message);
        }
    
        public async Task BroadcastMessage(string message)
        {
            await Clients.All.SendAsync(class="code-string">"ReceiveMessage", Context.User?.Identity?.Name, message);
        }
    
        public override async Task OnConnectedAsync()
        {
            _logger.LogInformation(class="code-string">"Client connected: {ConnectionId}", Context.ConnectionId);
            await base.OnConnectedAsync();
        }
    
        public override async Task OnDisconnectedAsync(Exception? exception)
        {
            _logger.LogInformation(
                class="code-string">"Client disconnected: {ConnectionId}, Reason: {Reason}",
                Context.ConnectionId, exception?.Message ?? class="code-string">"clean disconnect");
            await base.OnDisconnectedAsync(exception);
        }
    }

    Registration in Program.cs

    csharp
    var builder = WebApplication.CreateBuilder(args);
    
    builder.Services.AddSignalR(options =>
    {
        options.EnableDetailedErrors = builder.Environment.IsDevelopment();
        options.KeepAliveInterval = TimeSpan.FromSeconds(class="code-number">15);
        options.ClientTimeoutInterval = TimeSpan.FromSeconds(class="code-number">30);
        options.MaximumReceiveMessageSize = class="code-number">64 * class="code-number">1024; class=class="code-string">"code-comment">// class="code-number">64 KB
    });
    
    var app = builder.Build();
    
    app.MapHub<NotificationHub>(class="code-string">"/hubs/notifications");
    app.Run();

    Strongly-Typed Hubs

    String-based method names in `SendAsync("ReceiveMessage", ...)` are fragile. A typo compiles fine but fails silently at runtime. Strongly-typed hubs eliminate this entirely.

    Define the Client Interface

    csharp
    public interface INotificationClient
    {
        Task ReceiveNotification(string message);
        Task ReceiveMessage(string user, string message);
        Task UserJoinedGroup(string user, string group);
        Task UserLeftGroup(string user, string group);
        Task ReceiveDashboardUpdate(DashboardData data);
    }

    Implement the Strongly-Typed Hub

    csharp
    public class NotificationHub : Hub<INotificationClient>
    {
        public async Task SendNotification(string user, string message)
        {
            class=class="code-string">"code-comment">// Compile-time checked -- no more magic strings
            await Clients.User(user).ReceiveNotification(message);
        }
    
        public async Task BroadcastMessage(string message)
        {
            var sender = Context.User?.Identity?.Name ?? class="code-string">"Anonymous";
            await Clients.All.ReceiveMessage(sender, message);
        }
    
        public async Task SendDashboardUpdate(DashboardData data)
        {
            await Clients.Group(class="code-string">"dashboard-viewers").ReceiveDashboardUpdate(data);
        }
    }

    In real-time dashboards I've built, strongly-typed hubs caught multiple bugs during compilation that would have been silent runtime failures. The small upfront cost of defining the interface pays for itself immediately.

    Client-Server Communication Patterns

    SignalR supports several communication patterns, each suited to different scenarios.

    Server to Client

    The most common pattern. The server pushes data to connected clients without them requesting it.

    csharp
    class=class="code-string">"code-comment">// From within a Hub method
    await Clients.All.ReceiveMessage(user, message);          class=class="code-string">"code-comment">// All clients
    await Clients.Caller.ReceiveNotification(class="code-string">"Acknowledged");  class=class="code-string">"code-comment">// Only the caller
    await Clients.Others.ReceiveMessage(user, message);        class=class="code-string">"code-comment">// Everyone except caller
    await Clients.User(userId).ReceiveNotification(msg);       class=class="code-string">"code-comment">// Specific user
    await Clients.Group(class="code-string">"admins").ReceiveNotification(msg);    class=class="code-string">"code-comment">// Group members

    Client to Server

    Clients invoke hub methods directly. Here is a JavaScript client example:

    javascript
    const connection = new signalR.HubConnectionBuilder()
        .withUrl(class="code-string">"/hubs/notifications", {
            accessTokenFactory: () => getAccessToken()
        })
        .withAutomaticReconnect([class="code-number">0, class="code-number">2000, class="code-number">5000, class="code-number">10000, class="code-number">30000])
        .configureLogging(signalR.LogLevel.Information)
        .build();
    
    class=class="code-string">"code-comment">// Receive messages from server
    connection.on(class="code-string">"ReceiveMessage", (user, message) => {
        console.log(`${user}: ${message}`);
        appendMessageToUI(user, message);
    });
    
    connection.on(class="code-string">"ReceiveDashboardUpdate", (data) => {
        updateDashboard(data);
    });
    
    class=class="code-string">"code-comment">// Send messages to server
    async function sendMessage(message) {
        await connection.invoke(class="code-string">"BroadcastMessage", message);
    }
    
    class=class="code-string">"code-comment">// Start connection
    await connection.start();

    Invoking Hub Methods from Outside the Hub

    In production systems, you often need to push messages from background services, API controllers, or event handlers -- not just from within a hub method.

    csharp
    public class OrderProcessingService : BackgroundService
    {
        private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;
    
        public OrderProcessingService(
            IHubContext<NotificationHub, INotificationClient> hubContext)
        {
            _hubContext = hubContext;
        }
    
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                var updates = await GetPendingUpdates();
    
                foreach (var update in updates)
                {
                    await _hubContext.Clients.User(update.UserId)
                        .ReceiveNotification($class="code-string">"Order {update.OrderId} status: {update.Status}");
                }
    
                await Task.Delay(TimeSpan.FromSeconds(class="code-number">5), stoppingToken);
            }
        }
    }

    Groups and User Management

    Groups are the primary mechanism for organizing which clients receive which messages. They are lightweight, dynamic, and require no pre-registration.

    Group Operations

    csharp
    public class CollaborationHub : Hub<ICollaborationClient>
    {
        private readonly IGroupTracker _groupTracker;
    
        public CollaborationHub(IGroupTracker groupTracker)
        {
            _groupTracker = groupTracker;
        }
    
        public async Task JoinProject(string projectId)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, $class="code-string">"project-{projectId}");
    
            var userName = Context.User?.Identity?.Name ?? class="code-string">"Anonymous";
            await _groupTracker.AddUserToGroup(projectId, userName, Context.ConnectionId);
    
            await Clients.Group($class="code-string">"project-{projectId}")
                .UserJoinedGroup(userName, projectId);
        }
    
        public async Task LeaveProject(string projectId)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, $class="code-string">"project-{projectId}");
    
            var userName = Context.User?.Identity?.Name ?? class="code-string">"Anonymous";
            await _groupTracker.RemoveUserFromGroup(projectId, Context.ConnectionId);
    
            await Clients.Group($class="code-string">"project-{projectId}")
                .UserLeftGroup(userName, projectId);
        }
    
        public override async Task OnDisconnectedAsync(Exception? exception)
        {
            class=class="code-string">"code-comment">// Clean up all group memberships for this connection
            var groups = await _groupTracker.GetGroupsForConnection(Context.ConnectionId);
            foreach (var group in groups)
            {
                await _groupTracker.RemoveUserFromGroup(group, Context.ConnectionId);
                await Clients.Group($class="code-string">"project-{group}")
                    .UserLeftGroup(Context.User?.Identity?.Name ?? class="code-string">"Anonymous", group);
            }
    
            await base.OnDisconnectedAsync(exception);
        }
    }

    A critical detail: SignalR groups have no persistence. When a server restarts, all group memberships are lost. If your application needs durable group membership, maintain it in your own data store and rejoin groups on reconnection.

    Authentication with SignalR

    SignalR integrates directly with ASP.NET Core authentication. The same JWT or cookie authentication that protects your API endpoints protects your hubs.

    JWT Authentication for SignalR

    WebSocket connections cannot send custom HTTP headers after the initial handshake. SignalR solves this by accepting the token as a query string parameter during the negotiation phase.

    csharp
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidateAudience = true,
                ValidateLifetime = true,
                ValidateIssuerSigningKey = true,
                ValidIssuer = builder.Configuration[class="code-string">"Jwt:Issuer"],
                ValidAudience = builder.Configuration[class="code-string">"Jwt:Audience"],
                IssuerSigningKey = new SymmetricSecurityKey(
                    Encoding.UTF8.GetBytes(builder.Configuration[class="code-string">"Jwt:Key"]!))
            };
    
            class=class="code-string">"code-comment">// SignalR sends the token as a query string parameter
            options.Events = new JwtBearerEvents
            {
                OnMessageReceived = context =>
                {
                    var accessToken = context.Request.Query[class="code-string">"access_token"];
                    var path = context.HttpContext.Request.Path;
    
                    if (!string.IsNullOrEmpty(accessToken) &&
                        path.StartsWithSegments(class="code-string">"/hubs"))
                    {
                        context.Token = accessToken;
                    }
    
                    return Task.CompletedTask;
                }
            };
        });

    Securing Hub Methods

    csharp
    [Authorize]
    public class SecureHub : Hub<INotificationClient>
    {
        [Authorize(Roles = class="code-string">"Admin")]
        public async Task AdminBroadcast(string message)
        {
            await Clients.All.ReceiveNotification(message);
        }
    
        [Authorize(Policy = class="code-string">"PremiumUser")]
        public async Task PremiumFeature(string data)
        {
            await Clients.Caller.ReceiveNotification($class="code-string">"Premium result: {data}");
        }
    }

    Scaling SignalR with Redis Backplane

    A single server handles all its connections in memory. When you scale to multiple servers behind a load balancer, a client connected to Server A cannot receive messages sent from Server B. The Redis backplane solves this by broadcasting messages across all servers.

    Configuration

    csharp
    builder.Services.AddSignalR()
        .AddStackExchangeRedis(builder.Configuration.GetConnectionString(class="code-string">"Redis")!, options =>
        {
            options.Configuration.ChannelPrefix = RedisChannel.Literal(class="code-string">"SignalR");
        });

    How It Works

  • Client connects to Server A and joins the "dashboard" group
  • A background service on Server B sends a message to the "dashboard" group
  • Server B publishes the message to Redis
  • Server A subscribes to the Redis channel, receives the message, and forwards it to the client
  • When to Use Azure SignalR Service Instead

    For production workloads exceeding a few thousand concurrent connections, consider Azure SignalR Service. It offloads connection management entirely, eliminating the need for sticky sessions, Redis infrastructure, and connection-count capacity planning. You pay per unit, but the operational simplicity is often worth it.

    csharp
    builder.Services.AddSignalR()
        .AddAzureSignalR(builder.Configuration[class="code-string">"Azure:SignalR:ConnectionString"]);

    Error Handling and Reconnection Strategy

    Real-time connections are inherently fragile. Networks drop, servers restart, clients go to sleep. A robust reconnection strategy is non-negotiable for production systems.

    Server-Side Error Handling

    csharp
    public class ResilientHub : Hub<INotificationClient>
    {
        private readonly ILogger<ResilientHub> _logger;
    
        public ResilientHub(ILogger<ResilientHub> logger)
        {
            _logger = logger;
        }
    
        public async Task ProcessData(DataRequest request)
        {
            try
            {
                var result = await ValidateAndProcess(request);
                await Clients.Caller.ReceiveNotification($class="code-string">"Success: {result}");
            }
            catch (ValidationException ex)
            {
                class=class="code-string">"code-comment">// Send structured errors back to the caller
                _logger.LogWarning(ex, class="code-string">"Validation failed for {ConnectionId}", Context.ConnectionId);
                throw new HubException($class="code-string">"Validation failed: {ex.Message}");
            }
            catch (Exception ex)
            {
                class=class="code-string">"code-comment">// Log the full exception but send a safe message to the client
                _logger.LogError(ex, class="code-string">"Unexpected error for {ConnectionId}", Context.ConnectionId);
                throw new HubException(class="code-string">"An unexpected error occurred. Please try again.");
            }
        }
    }

    Throwing `HubException` sends the message to the client. Any other exception type results in a generic error message unless `EnableDetailedErrors` is on. Never enable detailed errors in production -- it leaks stack traces and internal state.

    Client-Side Reconnection

    javascript
    const connection = new signalR.HubConnectionBuilder()
        .withUrl(class="code-string">"/hubs/notifications")
        .withAutomaticReconnect({
            nextRetryDelayInMilliseconds: (retryContext) => {
                if (retryContext.elapsedMilliseconds < class="code-number">60000) {
                    return Math.random() * class="code-number">10000; class=class="code-string">"code-comment">// Random delay up to class="code-number">10 seconds
                }
                return null; class=class="code-string">"code-comment">// Stop retrying after class="code-number">60 seconds
            }
        })
        .build();
    
    connection.onreconnecting((error) => {
        showConnectionStatus(class="code-string">"Reconnecting...");
        console.warn(class="code-string">"Connection lost, attempting reconnect:", error);
    });
    
    connection.onreconnected((connectionId) => {
        showConnectionStatus(class="code-string">"Connected");
        console.log(class="code-string">"Reconnected with ID:", connectionId);
        class=class="code-string">"code-comment">// Rejoin groups after reconnection
        rejoinGroups();
    });
    
    connection.onclose((error) => {
        showConnectionStatus(class="code-string">"Disconnected");
        console.error(class="code-string">"Connection closed:", error);
        class=class="code-string">"code-comment">// Manual reconnect with exponential backoff
        setTimeout(() => startConnection(), class="code-number">5000);
    });
    
    async function startConnection() {
        try {
            await connection.start();
            showConnectionStatus(class="code-string">"Connected");
        } catch (err) {
            console.error(class="code-string">"Connection failed:", err);
            setTimeout(() => startConnection(), class="code-number">5000);
        }
    }

    The critical detail many teams miss: after reconnection, the client gets a new ConnectionId. Any group memberships from the previous connection are gone. You must rejoin groups explicitly. In real-time dashboards I've built, I maintain a client-side list of active subscriptions and replay them on every reconnect.

    SignalR vs WebSockets vs Server-Sent Events

    Each technology serves different use cases. Choosing the wrong one leads to unnecessary complexity or missing capabilities.

    | Aspect | SignalR | Raw WebSockets | Server-Sent Events (SSE) |

    |---|---|---|---|

    | Direction | Bidirectional | Bidirectional | Server to Client only |

    | Transport | Auto-negotiated | WebSocket only | HTTP streaming |

    | Reconnection | Built-in | Manual implementation | Built-in (EventSource) |

    | Browser Support | Universal (with fallback) | Modern browsers | Modern browsers |

    | Message Format | JSON/MessagePack | Raw bytes/text | Text only |

    | Groups/Users | Built-in | Manual implementation | Manual implementation |

    | Scaling | Redis/Azure backplane | Custom solution | Custom solution |

    | Auth Integration | ASP.NET Core native | Manual | Standard HTTP |

    | Complexity | Low | High | Low |

    | Best For | .NET full-stack apps | Custom protocols, games | Simple notifications, feeds |

    Choose SignalR when you are in the .NET ecosystem and need bidirectional communication with minimal boilerplate. Choose raw WebSockets when you need a custom binary protocol or are building something like a multiplayer game engine. Choose SSE when you only need server-to-client streaming and want the simplest possible implementation.

    Common SignalR Mistakes

    1. Storing State in Hub Instances

    Hubs are transient. A new instance is created for every method invocation. Any state stored as a field on the hub class is lost between calls.

    csharp
    class=class="code-string">"code-comment">// WRONG -- this counter resets on every call
    public class BadHub : Hub
    {
        private int _messageCount = class="code-number">0; class=class="code-string">"code-comment">// Always class="code-number">0 on each invocation
    
        public async Task SendMessage(string msg)
        {
            _messageCount++; class=class="code-string">"code-comment">// Always class="code-number">1
            await Clients.All.SendAsync(class="code-string">"Count", _messageCount);
        }
    }
    
    class=class="code-string">"code-comment">// CORRECT -- use an injected singleton service
    public class GoodHub : Hub
    {
        private readonly IMessageCounter _counter;
    
        public GoodHub(IMessageCounter counter) => _counter = counter;
    
        public async Task SendMessage(string msg)
        {
            var count = _counter.Increment();
            await Clients.All.SendAsync(class="code-string">"Count", count);
        }
    }

    2. Blocking the Hub with Long-Running Operations

    Hub methods run on the SignalR connection's thread. Blocking it prevents the client from receiving other messages.

    3. Forgetting Group Rejoin After Reconnection

    As mentioned above, reconnection creates a new connection. Groups are not automatically restored. This is the number one source of "it works in development but not in production" bugs.

    4. Not Configuring CORS Properly

    SignalR uses both HTTP (for negotiation) and WebSockets. CORS must allow both. A common mistake is configuring CORS for API endpoints but forgetting that SignalR negotiation is also an HTTP request.

    csharp
    builder.Services.AddCors(options =>
    {
        options.AddPolicy(class="code-string">"SignalRPolicy", policy =>
        {
            policy.WithOrigins(class="code-string">"https:class="code-commentclass="code-string">">//app.example.com")
                  .AllowAnyHeader()
                  .AllowAnyMethod()
                  .AllowCredentials(); class=class="code-string">"code-comment">// Required for SignalR
        });
    });

    5. Ignoring Message Size Limits

    The default maximum message size is 32 KB. Sending large payloads without adjusting this limit causes silent disconnects. For dashboards with large data payloads, I increase the limit explicitly and implement pagination for the initial data load.

    6. Not Using MessagePack for High-Throughput Scenarios

    JSON is the default protocol, but for high-frequency updates (stock tickers, game state, IoT telemetry), MessagePack binary serialization reduces payload size by 30-50% and improves serialization speed significantly.

    csharp
    builder.Services.AddSignalR()
        .AddMessagePackProtocol();

    Conclusion

    SignalR turns real-time communication from a systems-level challenge into a straightforward application-level feature. The strongly-typed hub pattern, built-in group management, and seamless authentication integration make it the natural choice for .NET applications that need live updates. The critical decisions are around scaling strategy (Redis vs Azure SignalR Service), reconnection handling, and understanding that hubs are transient. Get those right, and SignalR disappears into the background -- which is exactly what good infrastructure should do.

    I can help architect your real-time features and review your SignalR scaling strategy for production.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch