SignalR Performance Tuning in .NET
SignalR handles moderate loads well out of the box. But when you're pushing thousands of connections or high-frequency messages, default settings become bottlenecks. Here's how to tune SignalR for demanding real-time applications.
Transport Selection
SignalR negotiates the best transport automatically: WebSockets, Server-Sent Events, or Long Polling. WebSockets are the fastest — they provide full-duplex communication over a single TCP connection. If your infrastructure supports it, restrict to WebSockets only:
app.MapHub<DataHub>("/hubs/data", options =>
{
options.Transports = HttpTransportType.WebSockets;
});
On the client:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/data", {
transport: signalR.HttpTransportType.WebSockets,
skipNegotiation: true
})
.build();
Setting skipNegotiation: true with WebSockets-only mode eliminates the initial HTTP negotiate request, saving a round trip on every connection.
Buffer and Message Size Configuration
Tune buffer sizes based on your message patterns:
builder.Services.AddSignalR(options =>
{
// Maximum size of a single incoming hub message (default: 32 KB)
options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
// How long a connection can be inactive before timeout (default: 30s)
options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
// How often the server pings the client (default: 15s)
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
// Maximum parallel hub invocations per connection (default: 1)
options.MaximumParallelInvocationsPerClient = 5;
// Enable detailed error messages (development only)
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});
MaximumParallelInvocationsPerClient is particularly important. The default of 1 means hub invocations are processed sequentially per connection. If a client sends rapid requests and each takes time, they queue up. Increasing this allows parallel processing but requires thread-safe hub code.
Serialisation Performance
JSON is the default protocol. For high-throughput scenarios, consider MessagePack:
builder.Services.AddSignalR()
.AddMessagePackProtocol();
If you stick with JSON, configure System.Text.Json for performance:
builder.Services.AddSignalR()
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.CamelCase;
options.PayloadSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
// Add source-generated serialiser contexts for AOT performance
options.PayloadSerializerOptions.TypeInfoResolverChain
.Add(AppJsonContext.Default);
});
Source-generated JSON serialisation avoids runtime reflection and significantly reduces serialisation time and allocations.
[JsonSerializable(typeof(StockPrice))]
[JsonSerializable(typeof(Notification))]
[JsonSerializable(typeof(DashboardMetrics))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
Connection Management
Each WebSocket connection consumes resources. At scale, manage them carefully:
builder.WebHost.ConfigureKestrel(options =>
{
// Increase concurrent connection limits
options.Limits.MaxConcurrentConnections = 10000;
options.Limits.MaxConcurrentUpgradedConnections = 10000;
});
Monitor connection counts and implement connection limits per user to prevent abuse:
public class ConnectionLimitFilter : IHubFilter
{
private readonly ConcurrentDictionary<string, int> _connectionCounts = new();
private const int MaxConnectionsPerUser = 5;
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext context,
Func<HubInvocationContext, ValueTask<object?>> next)
{
return await next(context);
}
public Task OnConnectedAsync(
HubLifetimeContext context,
Func<HubLifetimeContext, Task> next)
{
var userId = context.Context.UserIdentifier;
if (userId is not null)
{
var count = _connectionCounts.AddOrUpdate(userId, 1, (_, c) => c + 1);
if (count > MaxConnectionsPerUser)
{
_connectionCounts.AddOrUpdate(userId, 0, (_, c) => c - 1);
throw new HubException("Too many connections.");
}
}
return next(context);
}
public Task OnDisconnectedAsync(
HubLifetimeContext context,
Exception? exception,
Func<HubLifetimeContext, Exception?, Task> next)
{
var userId = context.Context.UserIdentifier;
if (userId is not null)
{
_connectionCounts.AddOrUpdate(userId, 0, (_, c) => Math.Max(0, c - 1));
}
return next(context, exception);
}
}
Reducing Message Frequency
The fastest message is one you don't send. Batch updates instead of sending individual messages:
public class BatchedBroadcaster : BackgroundService
{
private readonly IHubContext<DashboardHub, IDashboardClient> _hub;
private readonly Channel<MetricUpdate> _channel;
public BatchedBroadcaster(
IHubContext<DashboardHub, IDashboardClient> hub)
{
_hub = hub;
_channel = Channel.CreateBounded<MetricUpdate>(1000);
}
public ValueTask EnqueueAsync(MetricUpdate update)
=> _channel.Writer.WriteAsync(update);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var batch = new List<MetricUpdate>(100);
while (!stoppingToken.IsCancellationRequested)
{
// Wait for at least one item
await _channel.Reader.WaitToReadAsync(stoppingToken);
// Drain all available items
while (_channel.Reader.TryRead(out var update))
{
batch.Add(update);
if (batch.Count >= 100) break;
}
if (batch.Count > 0)
{
await _hub.Clients.Group("dashboard")
.MetricsBatchUpdated(batch);
batch.Clear();
}
// Don't send more than 10 times per second
await Task.Delay(100, stoppingToken);
}
}
}
WebSocket Compression
.NET 7+ supports per-message WebSocket compression:
app.MapHub<DataHub>("/hubs/data", options =>
{
options.Transports = HttpTransportType.WebSockets;
options.WebSockets.DotNetObjectSerializerOptions =
new WebSocketOptions
{
// No direct compression config here — use Kestrel options
};
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureEndpointDefaults(listenOptions =>
{
// WebSocket compression is handled at the middleware level
});
});
Enable compression through the WebSocket middleware:
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
});
Be cautious with compression — it trades CPU for bandwidth. Profile to ensure it's a net win for your payload sizes.
Monitoring and Diagnostics
Measure before you optimise. Add SignalR-specific metrics:
builder.Services.AddSignalR()
.AddHubOptions<DataHub>(options =>
{
options.AddFilter<MetricsHubFilter>();
});
public class MetricsHubFilter : IHubFilter
{
private static readonly Counter<long> MessagesReceived =
SignalRMeter.CreateCounter<long>("signalr.messages.received");
private static readonly Histogram<double> InvocationDuration =
SignalRMeter.CreateHistogram<double>("signalr.invocation.duration.ms");
private static readonly Meter SignalRMeter = new("App.SignalR");
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext context,
Func<HubInvocationContext, ValueTask<object?>> next)
{
MessagesReceived.Add(1,
new KeyValuePair<string, object?>("method", context.HubMethodName));
var sw = Stopwatch.StartNew();
var result = await next(context);
sw.Stop();
InvocationDuration.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("method", context.HubMethodName));
return result;
}
}
Key Takeaways
- Use WebSockets-only with
skipNegotiationfor the fastest connections. - Increase
MaximumParallelInvocationsPerClientif hub methods can safely run concurrently. - Use MessagePack or source-generated JSON for faster serialisation.
- Batch messages to reduce per-message overhead.
- Monitor with hub filters and metrics to identify actual bottlenecks.
- Limit connections per user to prevent resource exhaustion.