SignalR At Extreme Connection Counts

SignalR feels simple when you have a chat window, a live dashboard, or a small notification feature. Add a hub, connect from the browser, call a method, broadcast to a group, job done. That simplicity is the point. It lets you build real-time features without manually managing WebSockets, reconnect logic, transport negotiation, connection IDs and message dispatch.
The story changes when the number of connected clients gets very large. At ten clients, SignalR feels like a feature. At ten thousand clients, it becomes infrastructure. At one hundred thousand clients, your design has to account for memory, sockets, load balancers, fan-out cost, reconnect storms, group membership, slow clients, authentication tokens, deployment strategy and observability. The hub code might still look small, but the system around it decides whether it survives. This is where a lot of developers get caught. They treat SignalR like a normal HTTP endpoint. It uses ASP.NET Core, it runs through Kestrel, it fits nicely into the same application, so it must scale like the rest of the API. That assumption is dangerous. A normal HTTP request arrives, gets processed, returns, and releases most of the resources it used. A SignalR connection hangs around. It sits there consuming memory, TCP state, buffers, timers and operational attention even when the user is idle.
That doesnt make SignalR a bad choice. It means you need to design for the traffic.
Connection count and message throughput are different problems
The first mistake is treating connection count and message throughput as the same thing. They are related, but they stress the system in different ways. A high connection count puts pressure on memory, sockets, load balancers and connection lifetime management. If you have 100,000 clients connected but only send a tiny message every few minutes, the main challenge is holding those connections safely and cheaply. High message throughput puts pressure on CPU, serialisation, allocations, network bandwidth and fan-out. If you have 5,000 clients and send updates twenty times per second, the number of connections may look modest while the message volume is brutal.
The worst case is the combination of both. Many clients, frequent messages, large payloads, broad broadcasts and unpredictable reconnects. That is where the architecture becomes more important than the hub method.
This is the first design question I would ask before writing code, are we trying to support a huge number of mostly idle connections, a smaller number of very active connections, or both? The answer changes the design.
Persistent connections change the server model
ASP.NET Core SignalR is built on persistent connections. Microsoft’s own hosting and scaling guidance calls out that persistent connections consume TCP connection resources and extra memory, and that servers can hit connection limits under high traffic. That is the part many developers skip over because local development hides it completely. When you test on your laptop with twenty browser tabs, everything looks fine. When a real deployment has 50,000 mobile clients connected through a load balancer, the application behaves more like a connection platform than a normal web API.
The server has to track active connections. The load balancer has to hold them. Firewalls and proxies have to tolerate them. Kubernetes or App Service needs to drain them during deployments. Clients need to reconnect cleanly when something moves. Metrics need to show whether connections are rising, dropping, churning or concentrating on a small set of nodes.
A basic SignalR hub hides most of this.
using Microsoft.AspNetCore.SignalR;
public sealed class NotificationsHub : Hub<INotificationClient>
{
public async Task JoinTenant(string tenantId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}
public async Task LeaveTenant(string tenantId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}
}
public interface INotificationClient
{
Task NotificationReceived(NotificationMessage message);
}
public sealed record NotificationMessage(
string Id,
string Type,
string Title,
string Body,
DateTimeOffset CreatedAt);
The code is clean, which is good. The risk is assuming the clean code means the runtime problem is small. It doesnt. The hub is the entry point. The real scale work sits around it.
Start with the traffic shape
Before deciding whether to host SignalR yourself, use Redis backplane, or offload to Azure SignalR Service, you need to understand the traffic. For a live dashboard, the server usually pushes frequent updates to many clients. For chat, users send messages to smaller groups. For notifications, most users sit idle and occasionally receive a short payload. For collaborative editing, the system handles many small updates with low latency expectations. For market prices, sports scores or telemetry dashboards, message volume and fan-out can become the main problem. Those are different systems even when they all use SignalR.
A useful way to model the load is to split it into connection count, average message size, send frequency, fan-out scope and reconnect rate. A tiny message sent to one user is cheap. The same message sent to 100,000 clients is a bandwidth event. A 20 KB update every five seconds to 100,000 clients is a very different system from a 500 byte notification every ten minutes.
Global broadcast is the easiest API to write and the fastest way to burn through network capacity. If every update goes to everyone, the code stays neat while the infrastructure pays the bill.
Keep hub methods thin
A SignalR hub method should be treated like a hot path. It should authenticate the user, validate the small amount of data it needs, update connection or group state when needed, and get out quickly. Expensive work should move away from the hub.
That means no database-heavy logic inside high-frequency hub methods. No external HTTP calls per incoming message. No large object graphs. No logging full payloads on every send. No synchronous blocking. No pretending a hub method is a controller action with a longer-lived connection. A thin hub keeps connection handling predictable.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
[Authorize]
public sealed class LiveOrdersHub : Hub<ILiveOrdersClient>
{
private readonly ISubscriptionAuthoriser _subscriptions;
public LiveOrdersHub(ISubscriptionAuthoriser subscriptions)
{
_subscriptions = subscriptions;
}
public async Task SubscribeToOrderBook(string bookId, CancellationToken stopToken)
{
var userId = Context.UserIdentifier;
if (userId is null)
{
throw new HubException("The connection is not associated with a user.");
}
var allowed = await _subscriptions.CanSubscribeToBookAsync(
userId,
bookId,
stopToken);
if (!allowed)
{
throw new HubException("The user cannot subscribe to this order book.");
}
await Groups.AddToGroupAsync(Context.ConnectionId, $"order-book:{bookId}", stopToken);
}
}
public interface ILiveOrdersClient
{
Task OrderBookUpdated(OrderBookUpdate update);
}
That database call in SubscribeToOrderBook is acceptable because it happens when the user subscribes, not on every server push. If you make the same sort of call for every update going out to every connection, the hub will eventually become a very expensive router.
Push from the backend, not from random request handlers
In a small app, it is common to inject IHubContext<T> into a controller and push messages directly after something happens. That works. Its also easy to turn into a mess. At scale, it is usually cleaner to separate domain events from SignalR delivery. The business operation writes the state change and publishes an event. A dedicated dispatcher reads events, decides which clients or groups should receive the message, shapes the payload, and sends it through SignalR.
This gives you better control over spikes. If a burst of business events arrives, the dispatcher can batch, throttle, collapse or drop low-value updates before they hit the connected clients. That is much harder when every request handler pushes directly to SignalR as part of the original transaction. A dispatcher can be a BackgroundService in the same application, a separate worker, an Azure Function, a containerised worker, or a dedicated real-time delivery service. The choice depends on the size of the system. The important part is the separation.
using Microsoft.AspNetCore.SignalR;
public sealed class OrderBookUpdateDispatcher : BackgroundService
{
private readonly IOrderBookEventReader _reader;
private readonly IHubContext<LiveOrdersHub, ILiveOrdersClient> _hub;
private readonly ILogger<OrderBookUpdateDispatcher> _logger;
public OrderBookUpdateDispatcher(
IOrderBookEventReader reader,
IHubContext<LiveOrdersHub, ILiveOrdersClient> hub,
ILogger<OrderBookUpdateDispatcher> logger)
{
_reader = reader;
_hub = hub;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stopToken)
{
await foreach (var update in _reader.ReadAsync(stopToken))
{
try
{
await _hub.Clients
.Group($"order-book:{update.BookId}")
.OrderBookUpdated(update);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dispatch order book update for {BookId}", update.BookId);
}
}
}
}
This still sends one update at a time. A more serious version would coalesce frequent changes, apply backpressure and avoid sending stale intermediate state when newer state has already arrived.
Coalescing is often more valuable than raw speed
Many real-time systems send too much data. They push every intermediate change because it feels technically honest. Users often need the latest state, not every transition. A live dashboard does not always need fifty updates per second. A user interface may only render at screen refresh speed. A price panel may need the latest price, not every discarded intermediate tick. A claims dashboard may need a count every second, not every row-level change. A monitoring view may need a summary per interval, not a flood of individual events.
Coalescing means you keep the newest value and send at a controlled cadence. The result is usually better for the server and better for the client.
public sealed class CoalescingBroadcaster<T>
{
private readonly Channel<T> _channel;
private T? _latest;
public CoalescingBroadcaster()
{
_channel = Channel.CreateBounded<T>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
}
public bool TryPublish(T item)
{
return _channel.Writer.TryWrite(item);
}
public async IAsyncEnumerable<T> ReadLatestEvery(
TimeSpan interval,
[EnumeratorCancellation] CancellationToken stopToken)
{
using var timer = new PeriodicTimer(interval);
while (!stopToken.IsCancellationRequested)
{
while (_channel.Reader.TryRead(out var item))
{
_latest = item;
}
if (_latest is not null)
{
yield return _latest;
_latest = default;
}
await timer.WaitForNextTickAsync(stopToken);
}
}
}
That pattern will not suit audit events or chat messages, where every message must be delivered or persisted. It is excellent for dashboards, counters, telemetry snapshots and live status panels where the latest state is what the user needs.
Message size can quietly destroy capacity
SignalR supports JSON by default and can also use MessagePack. Microsoft’s SignalR documentation describes MessagePack as a binary protocol that generally creates smaller messages than JSON. Smaller messages reduce network pressure and often reduce the cost of broad fan-out, especially when you are sending the same payload to many clients.
MessagePack is not a magic switch. You still need compatible clients, versioned contracts, sensible payload design and testing. If your payload is huge because you send the whole aggregate every time, changing the wire format only hides the design problem.
The better fix is to send smaller messages.
public sealed record PoorLiveUpdate(
string TenantId,
IReadOnlyCollection<OrderDto> AllOrders,
IReadOnlyCollection<CustomerDto> Customers,
IReadOnlyCollection<ActivityDto> Activity,
DateTimeOffset GeneratedAt);
public sealed record BetterLiveUpdate(
string OrderId,
string Status,
DateTimeOffset UpdatedAt);
Large messages also affect memory. SignalR has configurable buffer and message limits. The default maximum incoming hub message size is 32 KB, and increasing that value can increase denial-of-service risk and memory pressure. At extreme connection counts, every extra buffer decision multiplies.
A sensible server configuration is explicit about those limits.
using Microsoft.AspNetCore.Http.Connections;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddSignalR(options =>
{
options.MaximumReceiveMessageSize = 16 * 1024;
options.StreamBufferCapacity = 5;
options.EnableDetailedErrors = false;
options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
})
.AddMessagePackProtocol();
var app = builder.Build();
app.MapHub<LiveOrdersHub>("/hubs/live-orders", options =>
{
options.Transports = HttpTransportType.WebSockets;
options.ApplicationMaxBufferSize = 16 * 1024;
options.TransportMaxBufferSize = 16 * 1024;
options.WebSockets.CloseTimeout = TimeSpan.FromSeconds(5);
});
app.Run();
The exact numbers should come from testing. The principle is to avoid accidentally allowing large messages and large buffers because nobody made an intentional decision.
WebSockets should be your default for serious scale
SignalR can fall back to Server-Sent Events or Long Polling depending on the environment and client support. That fallback behaviour is useful. It also changes the scaling model. Long Polling creates repeated HTTP requests. The SignalR configuration documentation lists a default long polling timeout of 90 seconds, and reducing it causes clients to issue new poll requests more often. That can create extra request churn under load. For very large connection counts, WebSockets are usually the cleaner transport because the connection is persistent and bidirectional.
For internal systems where you control the clients, restricting the transport to WebSockets can make behaviour more predictable.
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/live-orders", {
transport: signalR.HttpTransportType.WebSockets,
skipNegotiation: true
})
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: retryContext => {
const baseDelay = Math.min(30_000, 1_000 * Math.pow(2, retryContext.previousRetryCount));
const jitter = Math.floor(Math.random() * 1_000);
return baseDelay + jitter;
}
})
.build();
connection.on("OrderBookUpdated", update => {
renderOrderBookUpdate(update);
});
await connection.start();
That client includes jitter in the reconnect delay. This matters during outages, deployments and network blips. If 100,000 clients all reconnect on the same schedule, the platform gets hit by a second incident just as it is trying to recover from the first one.
Reconnect storms are a production problem, not a client detail
Reconnect logic looks harmless until a region, load balancer, proxy, mobile network or deployment causes a large number of clients to reconnect at once. Then every layer gets hit together. Clients negotiate, authenticate, reconnect, rejoin groups and request missed state. The database may get hit if group membership or user permissions are loaded on connection. The identity provider may get hit if tokens are refreshed. The app may allocate heavily while rebuilding connection state.
This is why connection start should be cheap. Avoid loading half the user profile when the connection opens. Avoid expensive group rehydration if it can be derived from claims or cached subscription state. Avoid direct database dependency for every reconnect when possible. Put limits on reconnect behaviour at the client and gateway. Watch connection churn, not just active connections.
A practical pattern is to make the client explicitly resubscribe after reconnect, then keep the server-side subscription check cheap and cached.
public sealed class CachedSubscriptionAuthoriser : ISubscriptionAuthoriser
{
private readonly IMemoryCache _cache;
private readonly ISubscriptionStore _store;
public CachedSubscriptionAuthoriser(
IMemoryCache cache,
ISubscriptionStore store)
{
_cache = cache;
_store = store;
}
public async Task<bool> CanSubscribeToBookAsync(
string userId,
string bookId,
CancellationToken stopToken)
{
var cacheKey = $"sub:{userId}:{bookId}";
if (_cache.TryGetValue(cacheKey, out bool allowed))
{
return allowed;
}
allowed = await _store.CanSubscribeToBookAsync(userId, bookId, stopToken);
_cache.Set(cacheKey, allowed, TimeSpan.FromMinutes(5));
return allowed;
}
}
This is a simple example. In a multi-node setup, you may need distributed cache, short TTLs, explicit invalidation or permission versioning. The important point is to keep reconnect cost under control.
Sticky sessions are still part of the conversation
When you host SignalR across multiple servers, the same server process generally needs to handle the requests for a specific connection. Microsoft’s SignalR scale guidance says sticky sessions, also called session affinity, are required in server farm scenarios unless you are in one of the documented exceptions such as using Azure SignalR Service or using WebSockets only with negotiation skipped. This is where normal stateless API instincts can mislead you. A REST API can usually route the next request to any healthy node. A SignalR connection has state attached to the server handling it. Group membership, connection ID and in-memory connection state make routing behaviour important.
With self-hosted SignalR, you need to be deliberate about the load balancer.
A Redis backplane helps SignalR scale out messages across app servers, but it does not remove the need to think about routing and affinity in the common hosting model. It also introduces another shared dependency. Redis is fast, but it still has capacity, network latency, operational failure modes and blast radius. For small and medium systems, Redis backplane can be a reasonable scale-out step. For very large connection counts, I would usually look hard at Azure SignalR Service or a dedicated real-time tier before asking the main application fleet to hold every connection itself.
Azure SignalR Service changes the shape of the system
Azure SignalR Service offloads the client connections from your application servers. Your app still owns the business logic, hubs and message publishing, but the managed service handles the large set of persistent client connections. That shift is important. Your app servers no longer need to hold every client WebSocket directly. They can scale more like normal application workers while Azure SignalR Service handles the connection layer. The service also has its own scaling model, units, metrics and limits. Microsoft’s Azure SignalR documentation describes scale-up and scale-out options, and Premium tier supports autoscale based on metrics such as Server Load.
This architecture is usually cleaner for serious internet-facing scale. The app handles business decisions. The managed real-time service handles connection fan-out. You still need to design payloads, groups, retry behaviour and metrics properly, but you have moved a large operational concern out of your app process.
The decision is not only technical. There is cost, regional availability, service limits, networking, private connectivity, compliance and operational ownership to consider. Running everything yourself can look cheaper until the team has to manage reconnect storms, capacity planning and 24/7 incidents. A managed service can look expensive until you price the engineering effort of doing it badly in-house.
Group design decides fan-out cost
Groups are one of the most important SignalR concepts at scale. They let you target messages to the right audience instead of broadcasting everything. Good group design reduces network usage, client work and server fan-out.
A poor group strategy creates accidental broadcast. A better one matches the real audience shape.
public static class SignalRGroups
{
public static string Tenant(string tenantId) => $"tenant:{tenantId}";
public static string UserNotifications(string userId) => $"user-notifications:{userId}";
public static string OrderBook(string bookId) => $"order-book:{bookId}";
public static string Claim(string claimId) => $"claim:{claimId}";
}
Do not be afraid of many groups. Be afraid of groups that are too broad and updates that are too frequent. A group with 50 clients receiving a useful update is usually better than a tenant-wide group with 20,000 clients receiving data most of them ignore. The client should not receive a firehose and decide what it cares about. That pushes compute, bandwidth and battery cost onto the client while still making the server do broad fan-out. Filter before sending.
Slow clients are part of the design
At extreme connection counts, some clients will be slow. Some will sit on weak mobile networks. Some will go through corporate proxies. Some will pause in background tabs. Some will disconnect halfway through a burst. A design that assumes all clients consume messages at the same speed will suffer. You need a policy for slow consumers. For chat, you may persist messages and let the client catch up. For dashboards, you may drop intermediate updates and send the latest snapshot. For alerts, you may keep a small pending queue and then force a resync. For collaborative editing, you need a more careful protocol. The mistake is allowing unbounded buffering. If a client cannot keep up and the server keeps buffering messages for that client, memory pressure grows exactly when the system is already under load.
A simple rule helps, every real-time feature should define what can be dropped, what must be persisted, and what requires resync.
SignalR is excellent for delivery and interaction. It should not become the only source of truth for important business state. If the message must survive disconnects, persist it somewhere other than the connection.
Do not let authentication become your bottleneck
SignalR connections usually authenticate during connection setup. That part needs care at scale. If every reconnect creates expensive identity lookups, permission queries or token validation work, authentication becomes part of the reconnect storm.
Claims should carry the small amount of identity information needed for common routing decisions. Permission checks should be cached where safe. Tokens should be short enough to be secure, but the renewal model should not cause huge waves of clients to refresh at the same time. Large tokens can also cause practical problems because headers and URLs have limits depending on the transport and hosting path.
For browser clients, token handling often uses accessTokenFactory.
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/live-orders", {
transport: signalR.HttpTransportType.WebSockets,
skipNegotiation: true,
accessTokenFactory: async () => {
return await tokenProvider.getAccessToken();
}
})
.withAutomaticReconnect()
.build();
Server-side authorisation should still happen. A connection being authenticated does not mean every group subscription is valid. The user may be allowed to connect but not allowed to subscribe to a specific tenant, order book, claim, project or room.
Deployments need connection draining
Normal HTTP APIs are relatively easy to roll. Stop sending new requests to an instance, wait for in-flight requests to finish, terminate the process. SignalR makes this harder because connections can be long lived. If you terminate instances aggressively, clients reconnect. If you roll a large fleet too quickly, you create a reconnect wave. If reconnect requires expensive state rebuilding, deployment becomes a load event. If clients reconnect without jitter, the load event becomes synchronised. A production deployment strategy should include connection draining, sensible termination grace periods, rolling updates, readiness probes, client reconnect jitter and dashboard visibility into connection churn.
In Kubernetes, the details depend on ingress, cloud provider and hosting model, but the shape is the same.
apiVersion: apps/v1
kind: Deployment
metadata:
name: realtime-api
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: realtime-api
image: example.azurecr.io/realtime-api:latest
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
failureThreshold: 2
The readiness endpoint should tell the platform whether the instance should receive new traffic. It should not claim the app is ready before SignalR dependencies, caches and backplanes are actually usable.
Observability has to include connection behaviour
For a normal API, you look at request rate, latency, errors, CPU, memory and dependency calls. For SignalR, those are still useful, but they are not enough. You need to know current connections, connection start rate, connection stop rate, reconnect rate, average connection duration, messages sent, messages received, group counts, send failures, dropped updates, queue lag, payload size, slow consumer behaviour, app server distribution and Azure SignalR Service load if you use the managed service.
Microsoft documents .NET counters for ASP.NET Core SignalR under Microsoft.AspNetCore.Http.Connections, including current connections and total connections started. That is a good starting point when you need to understand what the server is actually doing.
dotnet-counters monitor \
--process-id 12345 \
Microsoft.AspNetCore.Http.Connections \
Microsoft-AspNetCore-Server-Kestrel \
System.Runtime
For production, those signals should flow into your normal observability stack. OpenTelemetry metrics, Application Insights, Prometheus, Grafana or Azure Monitor can all work. The important part is having SignalR-specific visibility before the first incident. A useful dashboard should show connection count over time, connection churn, messages per second, outbound bandwidth, failed sends, reconnect rate, app instance distribution, CPU, GC heap size, thread pool queue length and the health of the backplane or managed SignalR service.
Load testing SignalR needs different thinking
A /ping benchmark tells you almost nothing about SignalR capacity. Even a normal HTTP load test misses the point if it does not hold persistent connections and simulate real message patterns. A good SignalR load test should model connection ramp-up, idle connected users, group subscription, message fan-out, reconnects, slow clients, large payloads, small payloads, deployment interruption and dependency failure. You need to test the boring case and the ugly case.
For quick protocol-level experiments, a .NET console client can create many connections from multiple machines. One load generator will usually become the bottleneck before your real system does, so distribute the test clients.
using Microsoft.AspNetCore.SignalR.Client;
var connections = new List<HubConnection>();
var connectionCount = int.Parse(args[0]);
var hubUrl = args[1];
for (var i = 0; i < connectionCount; i++)
{
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.AddMessagePackProtocol()
.WithAutomaticReconnect()
.Build();
connection.On<OrderBookUpdate>("OrderBookUpdated", update =>
{
// Keep this tiny or the load generator becomes the bottleneck.
});
await connection.StartAsync();
await connection.InvokeAsync("SubscribeToOrderBook", "main");
connections.Add(connection);
if (i % 100 == 0)
{
Console.WriteLine($"Connected {i} clients");
await Task.Delay(250);
}
}
Console.WriteLine($"Connected {connections.Count} clients. Press enter to stop.");
Console.ReadLine();
This is only a starting point. Serious testing needs multiple load generators, realistic client code, real authentication behaviour, realistic payloads and clear pass or fail criteria. The result you care about is not “did it connect once?” It is whether the system can hold the target connection count, send at the required rate, recover from disruption and keep latency within the product’s tolerance.
What I would build for serious scale
For a small internal tool, I would keep SignalR inside the ASP.NET Core application and use WebSockets through a load balancer configured correctly. I would keep hub methods thin, avoid broad broadcasts, add basic metrics and move on.
For a medium system with multiple app instances, I would either use Azure SignalR Service or a Redis backplane depending on hosting constraints, expected growth and team experience. I would add explicit group design, message size limits, reconnect jitter, health checks, deployment draining and proper dashboards.
For a large internet-facing system, I would strongly consider a dedicated real-time delivery tier. That might be Azure SignalR Service, a separate SignalR fleet, or a more specialised messaging gateway depending on requirements. The main API would publish domain events. A dispatcher would shape and coalesce messages. The real-time tier would hold connections and push to users. The database would remain the source of truth, not the thing every live update depends on.
That separation gives the system room to breathe. The business API can focus on correctness. The dispatcher can focus on shaping events. The real-time tier can focus on connections. The client can focus on rendering useful state. When something goes wrong, you have clearer places to look.
The engineering trade-off
SignalR gives .NET developers a very productive real-time model. That productivity is real. You can build features quickly, stay inside ASP.NET Core, use C#, share auth, use strongly typed hubs and integrate with the rest of the application cleanly.
At extreme connection counts, the hidden cost is operational. Persistent connections turn your app into long-lived infrastructure. Broadcasts turn small events into bandwidth multipliers. Reconnects turn deployment choices into traffic spikes. Large payloads turn convenient DTOs into memory and network pressure. Missing metrics turn normal incidents into guesswork.
The better approach is to decide early what role SignalR plays in the system. Use it as the real-time delivery layer, not the source of truth. Keep the hub thin. Keep messages small. Design groups carefully. Avoid broad fan-out unless the product genuinely needs it. Treat reconnects as a first-class failure mode. Test with real connection behaviour, not just HTTP benchmarks. Offload the connection layer when the scale justifies it.
SignalR can handle serious workloads, but only when the surrounding architecture respects what makes real-time systems different from normal request and response APIs.
ASP.NET Core SignalR production hosting and scaling
ASP.NET Core SignalR configuration
Security considerations in ASP.NET Core SignalR
Overview of ASP.NET Core SignalR
Use MessagePack Hub Protocol in SignalR for ASP.NET Core
Redis backplane for ASP.NET Core SignalR scale-out
Messages and connections in Azure SignalR Service
Performance guide for Azure SignalR Service
How to scale an Azure SignalR Service instance





