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:

Example.cs
app.MapHub<DataHub>("/hubs/data", options =>
{
    options.Transports = HttpTransportType.WebSockets;
});

On the client:

script.js
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:

Program.cs
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:

Example.cs
builder.Services.AddSignalR()
    .AddMessagePackProtocol();

If you stick with JSON, configure System.Text.Json for performance:

Example.cs
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.

Example.cs
[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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
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