Edit

Use hubs in ASP.NET Core SignalR

By Rachel Appel and Kevin Griffin

The SignalR Hubs API enables connected clients to call methods on the server, facilitating real-time communication. The server defines methods that are called by the client, and the client defines methods that are called by the server. SignalR also enables indirect client-to-client communication, where the SignalR Hub provides the mediation. This approach allows sending messages between individual clients, groups, or to all connected clients. SignalR takes care of everything required to make real-time client-to-server and server-to-client communication possible.

This article describes how to configure hubs, send messages to clients, and allow servers to handle results from clients.

Configure SignalR hubs

Register the services required by SignalR hubs by calling the AddSignalR method in the Program file:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();
builder.Services.AddSignalR();

Configure SignalR endpoints by calling the MapHub method in the Program file:

app.MapRazorPages();
app.MapHub<ChatHub>("/Chat");

app.Run();

The SignalR middleware requires some services, which are configured by calling AddSignalR:

services.AddSignalR();

When adding SignalR functionality to an ASP.NET Core app, setup SignalR routes by calling MapHub in the Startup.Configure method's UseEndpoints callback:

app.UseEndpoints(endpoints =>
{
    endpoints.MapHub<ChatHub>("/chathub");
});

When adding SignalR functionality to an ASP.NET Core app, setup SignalR routes by calling UseSignalR in the Startup.Configure method:

app.UseSignalR(route =>
{
    route.MapHub<ChatHub>("/chathub");
});

Note

ASP.NET Core SignalR server-side assemblies are now installed with the .NET Core SDK. For more information, see SignalR assemblies in shared framework.

Create and use hubs

Create a hub by declaring a class that inherits from Hub. Add public methods to the class to make them callable from clients:

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
        => await Clients.All.SendAsync("ReceiveMessage", user, message);
}

You can specify a return type and parameters, including complex types and arrays, as you would in any C# method. SignalR handles the serialization and deserialization of complex objects and arrays in your parameters and return values. Hub method parameters, return values, and stream items can be C# union types only with the default JsonHubProtocol. The MessagePack and Newtonsoft.Json hub protocols don't support unions.

Accept cancellation in a hub method

Any hub method can declare a CancellationToken. The server supplies the token, so clients don't send an argument for it. The token is canceled when the client cancels the invocation or when the connection closes:

public class ReportHub : Hub
{
    public async Task<Report> GenerateReport(
        int reportId, CancellationToken cancellationToken)
    {
        // Pass cancellationToken to the work being performed.
    }
}

Cancellation is cooperative. The hub method decides whether and how quickly to stop, and SignalR sends a completion message for the invocation either way. Letting an OperationCanceledException propagate out of the hub method completes the invocation with an error.

A client can cancel any invocation that has an invocation ID. For how a client requests cancellation, see ASP.NET Core SignalR .NET client.

You can specify a return type and parameters, including complex types and arrays, as you would in any C# method. SignalR handles the serialization and deserialization of complex objects and arrays in your parameters and return values.

Use 'Context' object properties and methods

The Hub class includes a Context property that contains the following properties with information about the connection.

Property Description
ConnectionId Gets the unique ID for the connection, assigned by SignalR. There's one connection ID for each connection.
UserIdentifier Gets the user identifier. By default, SignalR uses the ClaimTypes.NameIdentifier property from the ClaimsPrincipal associated with the connection as the user identifier.
User Gets the ClaimsPrincipal associated with the current user.
Items Gets a key/value collection that can be used to share data within the scope of this connection. Data can be stored in this collection and it persists for the connection across different hub method invocations.
Features Gets the collection of features available on the connection. This collection isn't currently needed in most scenarios, so detailed documentation isn't yet available.
ConnectionAborted Gets a CancellationToken that notifies when the connection is aborted.

The Hub.Context property also contains the following methods:

Method Description
GetHttpContext Returns the HttpContext for the connection, or null if the connection isn't associated with an HTTP request. For HTTP connections, use this method to get information such as HTTP headers and query strings.
Abort Aborts the connection.

Use 'Clients' object properties and methods

The Hub class includes a Clients property that contains the following properties for communication between server and client:

Property Description
All Calls a method on all connected clients.
Caller Calls a method on the client that invoked the hub method.
Others Calls a method on all connected clients except the client that invoked the method.

The Hub.Clients property also contains the following methods:

Method Description
AllExcept Calls a method on all connected clients except for the specified connections.
Client Calls a method on a specific connected client.
Clients Calls a method on specific connected clients.
Group Calls a method on all connections in the specified group.
GroupExcept Calls a method on all connections in the specified group, except the specified connections.
Groups Calls a method on multiple groups of connections.
OthersInGroup Calls a method on a group of connections, excluding the client that invoked the hub method.
User Calls a method on all connections associated with a specific user.
Users Calls a method on all connections associated with the specified users.

Each property or method returns an object with a SendAsync method. The SendAsync method receives the name of the client method to call and any parameters.

The object returned by the Client and Caller methods also contain an InvokeAsync method, which can be used to wait for a result from the client.

Send messages to clients

To make calls to specific clients, use the properties of the Clients object. In the following example, there are three hub methods:

  • The SendMessage method sends a message to all connected clients by using the Clients.All property.
  • The SendMessageToCaller method sends a message back to the caller by using the Clients.Caller property.
  • The SendMessageToGroup method sends a message to all clients in the SignalR Users group.
public async Task SendMessage(string user, string message)
    => await Clients.All.SendAsync("ReceiveMessage", user, message);

public async Task SendMessageToCaller(string user, string message)
    => await Clients.Caller.SendAsync("ReceiveMessage", user, message);

public async Task SendMessageToGroup(string user, string message)
    => await Clients.Group("SignalR Users").SendAsync("ReceiveMessage", user, message);
public Task SendMessage(string user, string message)
{
    return Clients.All.SendAsync("ReceiveMessage", user, message);
}

public Task SendMessageToCaller(string user, string message)
{
    return Clients.Caller.SendAsync("ReceiveMessage", user, message);
}

public Task SendMessageToGroup(string user, string message)
{
    return Clients.Group("SignalR Users").SendAsync("ReceiveMessage", user, message);
}

Use strongly typed hubs

A drawback of using the SendAsync method is that it relies on a string to specify the client method to call. This design leaves code open to runtime errors if the method name is misspelled or missing from the client.

An alternative to using the SendAsync method is to strongly type the Hub class with Hub<T>. In the following example, the ChatHub client method is extracted into an interface named IChatClient:

public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
}

The interface can be used to refactor the preceding ChatHub example to make it strongly typed:

public class StronglyTypedChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
        => await Clients.All.ReceiveMessage(user, message);

    public async Task SendMessageToCaller(string user, string message)
        => await Clients.Caller.ReceiveMessage(user, message);

    public async Task SendMessageToGroup(string user, string message)
        => await Clients.Group("SignalR Users").ReceiveMessage(user, message);
}

Using Hub<IChatClient> enables compile-time checking of the client methods. This approach prevents issues caused by using strings because Hub<T> can only provide access to the methods defined in the interface. Using a strongly typed Hub<T> disables the ability to use the SendAsync method.

Note

The Async suffix isn't stripped from method names. Unless a client method is defined with .on('MyMethodAsync'), don't use MyMethodAsync as the name.

Request client results

In addition to making calls to clients, the server can request a result from a client. In this scenario, the server uses the ISingleClientProxy.InvokeAsync method and the client returns a result from its .On handler. Pass a CancellationToken that limits how long the server waits for a result. The following examples use a 30-second cancellation token.

There are two ways to use the API on the server.

You can call Client(...) or Caller on the Clients property in a Hub method:

public class ChatHub : Hub
{
    public async Task<string> WaitForMessage(string connectionId)
    {
        using var cancellationTokenSource =
            new CancellationTokenSource(TimeSpan.FromSeconds(30));

        var message = await Clients.Client(connectionId).InvokeAsync<string>(
            "GetMessage", cancellationTokenSource.Token);
        return message;
    }
}

Or, you can call Client(...) on an instance of IHubContext<T>:

async Task SomeMethod(IHubContext<MyHub> context)
{
    using var cancellationTokenSource =
        new CancellationTokenSource(TimeSpan.FromSeconds(30));

    string result = await context.Clients.Client(connectionID).InvokeAsync<string>(
        "GetMessage", cancellationTokenSource.Token);
}

Strongly typed hubs can also return values from interface methods:

public interface IClient
{
    Task<string> GetMessage();
}

public class ChatHub : Hub<IClient>
{
    public async Task<string> WaitForMessage(string connectionId)
    {
        string message = await Clients.Client(connectionId).GetMessage();
        return message;
    }
}

Clients return results in their .On(...) handlers, as shown in the following sections.

.NET client

hubConnection.On("GetMessage", async () =>
{
    Console.WriteLine("Enter message:");
    var message = await Console.In.ReadLineAsync();
    return message;
});

TypeScript client

hubConnection.on("GetMessage", async () => {
    let promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("message");
        }, 100);
    });
    return promise;
});

Java client

hubConnection.onWithResult("GetMessage", () -> {
    return Single.just("message");
});

Change the name of a hub method

By default, a server hub method name is the name of the .NET method. To change this default behavior for a specific method, use the HubMethodName attribute. The client should use this name instead of the .NET method name when invoking the method:

[HubMethodName("SendMessageToUser")]
public async Task DirectMessage(string user, string message)
    => await Clients.User(user).SendAsync("ReceiveMessage", user, message);

Inject services into a hub

SignalR registers an IHubActivator<THub> and creates an unregistered hub with ActivatorUtilities for each invocation. Singleton services injected into a hub outlive the hub instance, and injected transient services have a lifetime that matches the lifetime of the hub instance. Scoped service instances are created for each hub invocation and also match the lifetime of the hub; therefore, scoped and transient services exhibit equivalent lifetimes.

Hub constructor service injection is supported. In the following example, IDbContextFactory<TContext> is injected into a hub using a primary constructor to save messages to a database when SendMessage is called.

In the app's Program file using SQL Server as the example database provider and a connection string from configuration:

var connectionString = 
    builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

An example hub class:

public class ChatHub(IDbContextFactory<ApplicationDbContext> contextFactory) : Hub
{
    public async Task SendMessage(string user, string message)
    {
        using var context = await contextFactory.CreateDbContextAsync();

        var msgEntity = new Message { User = user, Content = message };
        context.Messages.Add(msgEntity);
        await context.SaveChangesAsync();

        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

If you're unable to use a factory and must inject a scoped DbContext directly, the framework automatically creates a dependency injection (DI) scope for the hub method invocation. The framework disposes the context as soon as the invocation/stream completes. However, you must ensure that the hub's methods don't execute concurrent database operations on the same context instance because DbContext isn't thread-safe.

Hub method service injection is also supported. In the following example, a scoped DbContext (ApplicationDbContext) only used for a quick write operation is injected into the specific method that requires it:

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message, 
        ApplicationDbContext context)
    {
        context.Messages.Add(new Message { User = user, Content = message });
        await context.SaveChangesAsync();

        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

To explicitly specify which parameters are resolved from DI in hub methods, specify the [FromServices] attribute or a custom attribute that implements IFromServiceMetadata on the hub method parameters that should be resolved from DI.

Set the DisableImplicitFromServicesParameters property (ASP.NET Core documentation) in case some parameters might come from DI and you instead want them to come from the client.

In the app's Program file:

builder.Services.AddSingleton<SomeCustomType>();
builder.Services.AddSingleton<IDatabaseService, DatabaseServiceImpl>();

builder.Services.AddSignalR(options =>
{
    options.DisableImplicitFromServicesParameters = true;
});

In the following hub method, only IDatabaseService is resolved from DI:

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message,
        SomeCustomType type,
        [FromServices] IDatabaseService dbService)
    {
        await dbService.SaveMessageAsync(user, message, type);
        await Clients.All.SendAsync("ReceiveMessage", user, message, type);
    }
}

Note

Implicit parameter inference makes use of IServiceProviderIsService, which is optionally implemented in DI configurations. If the app's DI container doesn't support this feature, injecting services into hub methods using implicit parameter inference isn't supported.

For database operations, adopting the factory pattern is preferred for overlapping operations within one invocation:

SignalR registers an IHubActivator<THub> and creates an unregistered hub with ActivatorUtilities for each invocation. Singleton services injected into a hub outlive the hub instance, and injected transient services have a lifetime that matches the lifetime of the hub instance. Scoped service instances are created for each hub invocation and also match the lifetime of the hub; therefore, scoped and transient services exhibit equivalent lifetimes.

Hub constructor service injection is supported with the factory pattern for creating database contexts using IDbContextFactory<TContext>. In the following example, IDbContextFactory<TContext> is injected into a hub's constructor and used to save messages to a database when SendMessage is called.

In Startup.ConfigureServices using SQL Server as the example database provider and a connection string from configuration:

var connectionString = Configuration.GetConnectionString("DefaultConnection");

services.AddDbContextFactory<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

An example hub class:

public class ChatHub : Hub
{
    private readonly IDbContextFactory<ApplicationDbContext> contextFactory;

    public ChatHub(IDbContextFactory<ApplicationDbContext> contextFactory)
    {
        this.contextFactory = contextFactory;
    }

    public async Task SendMessage(string user, string message)
    {
        using var context = contextFactory.CreateDbContext();

        context.Messages.Add(new Message { User = user, Content = message });
        await context.SaveChangesAsync();

        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

If you're unable to use a factory and must inject a scoped DbContext directly, the framework automatically creates a dependency injection (DI) scope for the hub method invocation. The framework disposes the context as soon as the invocation/stream completes. However, you must ensure that the hub's methods don't execute concurrent database operations on the same context instance because DbContext isn't thread-safe.

When injecting a scoped DbContext, the framework automatically creates a dependency injection (DI) scope for the hub method invocation. The framework disposes the context as soon as the invocation/stream completes. However, you must ensure that the hub's methods don't execute concurrent database operations on the same context instance because DbContext isn't thread-safe.

When injecting a scoped DbContext, the framework automatically creates a dependency injection (DI) scope for the hub method invocation. The framework disposes the context as soon as the invocation completes. However, you must ensure that the hub's methods don't execute concurrent database operations on the same context instance because DbContext isn't thread-safe.

Note

Upgrade the app to target .NET 5 or later to use the factory pattern for creating database contexts using IDbContextFactory<TContext>.

For database operations, adopting the factory pattern is preferred for overlapping operations within one invocation:

SignalR registers an IHubActivator<THub> and creates an unregistered hub with ActivatorUtilities for each invocation. Singleton services injected into a hub outlive the hub instance, and injected transient services have a lifetime that matches the lifetime of the hub instance. Scoped service instances are created for each hub invocation and also match the lifetime of the hub; therefore, scoped and transient services exhibit equivalent lifetimes.

Hub constructor service injection is supported. The framework automatically creates a dependency injection (DI) scope for the hub method invocation. The framework disposes the context as soon as the invocation completes. However, you must ensure that the hub's methods don't execute concurrent database operations on the same context instance because DbContext isn't thread-safe.

In the following example, DbContext is injected into a hub's constructor and used to save messages to a database when SendMessage is called.

In Startup.ConfigureServices using SQL Server as the example database provider and a connection string from configuration:

var connectionString = Configuration.GetConnectionString("DefaultConnection");

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));

In a hub class:

public class ChatHub : Hub
{
    private readonly ApplicationDbContext context;

    public ChatHub(ApplicationDbContext context)
    {
        this.context = context;
    }

    public async Task SendMessage(string user, string message)
    {
        context.Messages.Add(new Message { User = user, Content = message });
        await context.SaveChangesAsync();

        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

Because each hub method call is executed on a new hub instance, don't store state in a property of the hub class.

Don't instantiate a hub directly via DI. To send messages to a client from elsewhere in your app, use an IHubContext.

Use the await operator when calling an asynchronous method that depends on the hub staying alive. If you call an asynchronous method without await, the call can fail with the hub method completing before the asynchronous method finishes.

Supported: await Clients.All.SendAsync(...);
Not supported: Clients.All.SendAsync(...); (missing await)

For general guidance on DI, see Dependency injection in ASP.NET Core and its linked additional resources.

Keyed services support in dependency injection

The keyed services mechanism allows you to register and retrieve dependency injection services by using keys. A service is associated with a key by calling the AddKeyedSingleton method to register it. As an alternative, you can call the AddKeyedScoped or AddKeyedTransient method.

You access a registered service by specifying the key with the [FromKeyedServices] attribute. The following code shows how to use keyed services:

using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddKeyedSingleton<ICache, BigCache>("big");
builder.Services.AddKeyedSingleton<ICache, SmallCache>("small");

builder.Services.AddRazorPages();
builder.Services.AddSignalR();

var app = builder.Build();

app.MapRazorPages();
app.MapHub<MyHub>("/myHub");

app.Run();

public interface ICache
{
    object Get(string key);
}
public class BigCache : ICache
{
    public object Get(string key) => $"Resolving {key} from big cache.";
}

public class SmallCache : ICache
{
    public object Get(string key) => $"Resolving {key} from small cache.";
}

public class MyHub : Hub
{
    public void SmallCacheMethod([FromKeyedServices("small")] ICache cache)
    {
        Console.WriteLine(cache.Get("signalr"));
    }

    public void BigCacheMethod([FromKeyedServices("big")] ICache cache)
    {
        Console.WriteLine(cache.Get("signalr"));
    }
}

Limit per-connection streaming invocations

MaximumParallelInvocationsPerClient controls the number of non-streaming hub method invocations a client can run in parallel before they are queued. It does not apply to streaming hub invocations. Streaming invocations are intentionally excluded because they are expected to be long-running and concurrent, so a client can start any number of concurrent streams regardless of that setting.

To enforce a per-connection limit on streaming invocations, wrap the stream inside the hub method itself using a private helper that increments a counter before yielding items and decrements it in a finally block:

using System.Collections.Concurrent;
using System.Runtime.CompilerServices;

public class StreamingHub : Hub
{
    private static readonly ConcurrentDictionary<string, int> _activeStreams = new();
    private const int MaxConcurrentStreams = 2;

    public IAsyncEnumerable<int> Counter(
        int count,
        int delay,
        CancellationToken cancellationToken)
    {
        return WithLimit(Context.ConnectionId, GetCounter(count, delay, cancellationToken));
    }

    private async IAsyncEnumerable<int> GetCounter(
        int count,
        int delay,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (var i = 0; i < count; i++)
        {
            cancellationToken.ThrowIfCancellationRequested();
            yield return i;
            await Task.Delay(delay, cancellationToken);
        }
    }

    private async IAsyncEnumerable<T> WithLimit(
        string connectionId,
        IAsyncEnumerable<T> stream,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var current = _activeStreams.AddOrUpdate(
            connectionId,
            addValue: 1,
            updateValueFactory: (_, count) => count + 1);

        if (current > MaxConcurrentStreams)
        {
            Decrement(connectionId);
            throw new HubException(
                $"The connection is limited to {MaxConcurrentStreams} concurrent streaming invocations.");
        }

        try
        {
            await foreach (var item in stream.WithCancellation(cancellationToken))
            {
                yield return item;
            }
        }
        finally
        {
            Decrement(connectionId);
        }
    }

    private static void Decrement(string connectionId)
    {
        while (_activeStreams.TryGetValue(connectionId, out var current))
        {
            if (current <= 1)
            {
                if (_activeStreams.TryRemove(new KeyValuePair<string, int>(connectionId, current)))
                {
                    return;
                }
            }
            else if (_activeStreams.TryUpdate(connectionId, current - 1, current))
            {
                return;
            }
        }
    }
}

The key point is that WithLimit wraps the original IAsyncEnumerable<T> and holds the counter elevated for the full lifetime of the stream, not just until the first item is yielded. The finally block runs only when the client finishes consuming the stream, cancels it, or the connection drops.

If your streaming hub methods return ChannelReader<T> instead of IAsyncEnumerable<T>, a similar wrapper can be applied. It should use the same _activeStreams dictionary so both stream types share a single connection-level limit rather than each maintaining their own independent count.

Note

The _activeStreams dictionary is static so it is shared across all hub instances. If you prefer DI-managed state, register a singleton service that owns the dictionary and inject it into the hub constructor.

Handle events for a connection

The SignalR Hubs API provides the OnConnectedAsync and OnDisconnectedAsync virtual methods to manage and track connections. Override the OnConnectedAsync virtual method to perform actions when a client connects to the hub, such as adding it to a group:

public override async Task OnConnectedAsync()
{
    await Groups.AddToGroupAsync(Context.ConnectionId, "SignalR Users");
    await base.OnConnectedAsync();
}

Override the OnDisconnectedAsync virtual method to perform actions when a client disconnects. If the client disconnects intentionally, such as by calling connection.stop(), the exception parameter is set to null. However, if the client disconnects due to an error, such as a network failure, the exception parameter contains an exception that describes the failure:

public override async Task OnDisconnectedAsync(Exception? exception)
{
    await base.OnDisconnectedAsync(exception);
}
public override async Task OnDisconnectedAsync(Exception exception)
{
    await Clients.Group("SignalR Users").SendAsync("ReceiveMessage", "I", "disconnect");
    await base.OnDisconnectedAsync(exception);
}

The RemoveFromGroupAsync method doesn't need to be called within the OnDisconnectedAsync method because it's handled automatically.

Warning

Security warning: Exposing ConnectionId can lead to malicious impersonation if the SignalR server or client version is ASP.NET Core 2.2 or earlier.

A third virtual method, OnAuthenticationRefreshedAsync, runs when a connected client refreshes its authentication without reconnecting. Override it to perform actions after the refreshed principal is applied to the connection. Context.User reflects the refreshed principal when the method runs:

public class ChatHub : Hub
{
    public override Task OnAuthenticationRefreshedAsync()
    {
        return Clients.Caller.SendAsync(
            "AuthenticationRefreshed", Context.UserIdentifier);
    }
}

The method runs only for hubs that enable authentication refresh. A refresh doesn't change Context.UserIdentifier or reroute messages sent with Clients.User.

Two behaviors differ from the other connection events:

  • Client results aren't supported from this method, the same as in OnConnectedAsync. Send messages with SendAsync instead of requesting a result with InvokeAsync.
  • An exception thrown by this method is logged and then discarded. The connection stays open, the client isn't notified, and the refresh isn't rejected.

For the full feature, including how to enable it and how clients request a refresh, see SignalR authentication and authorization.

Handle errors

Exceptions thrown in your hub methods are sent to the client that invoked the method. On the JavaScript client, the invoke method returns a JavaScript Promise. When the client receives an error with a handler attached to the promise using catch, it's invoked and passed as a JavaScript Error object:

try {
  await connection.invoke("SendMessage", user, message);
} catch (err) {
  console.error(err);
}
connection.invoke("SendMessage", user, message).catch(err => console.error(err));

If your Hub throws an exception, connections aren't closed. By default, SignalR returns a generic error message to the client. For example:

Microsoft.AspNetCore.SignalR.HubException: An unexpected error occurred invoking 'MethodName' on the server.

Unexpected exceptions often contain sensitive information, such as the name of a database server in an exception triggered when the database connection fails. SignalR doesn't expose these detailed error messages by default as a security measure. For more information on why exception details are suppressed, see Security considerations in ASP.NET Core SignalR.

If you have an exceptional condition you do want to propagate to the client, you can use the HubException class. If you throw a HubException from your hub method, SignalR will send the entire message to the client, unmodified:

public Task ThrowException()
    => throw new HubException("This error will be sent to the client!");

Note

SignalR only sends the Message property of the exception to the client. The stack trace and other properties on the exception aren't available to the client.