SignalR is designed for real-time communication, but not every hub invocation completes quickly.

Some operations can take seconds or even minutes:

Previously, client-side cancellation support for regular, non-streaming SignalR hub invocations was limited. Streaming invocations could be cancelled, but cancelling a normal hub method required additional application-specific handling.

.NET 11 changes this behavior.

A .NET SignalR client can now pass a CancellationToken to a regular hub invocation. When the token is cancelled, SignalR sends a cancellation message to the server, and the CancellationToken parameter on the running hub method is triggered.

This creates a much cleaner cancellation flow:

Client
   |
   | InvokeAsync()
   |
   v
SignalR Hub
   |
   | Long-running operation
   |
   v
Client cancels
   |
   v
Cancellation message
   |
   v
Server CancellationToken triggered
   |
   v
Hub operation stops

The feature is especially useful for interactive applications where users may start an operation and then decide that they no longer need the result.

The Problem With Long-Running Hub Methods

Consider a hub method that generates a report:

public async Task<byte[]> GenerateReportAsync()
{
    return await reportService.GenerateAsync();
}

The client might call it like this:

var report = await connection.InvokeAsync<byte[]>(
    "GenerateReportAsync");

Now imagine that generating the report takes two minutes.

The user closes the report dialog after ten seconds.

Without server-side cancellation, the operation can continue running even though the client no longer needs the result.

That creates unnecessary work.

User starts report
       |
       v
Server starts processing
       |
       | User cancels
       v
Client no longer waits
       |
       | Server continues
       v
Report finishes

For expensive operations, this can waste:

What Changes in .NET 11?

.NET 11 allows a regular SignalR hub invocation to carry a cancellation request from the client to the server.

The client passes a CancellationToken:

using var cts = new CancellationTokenSource();

var result = await connection.InvokeAsync<string>(
    "LongRunningWork",
    cts.Token);

The hub method accepts a CancellationToken:

public async Task<string> LongRunningWork(
    CancellationToken cancellationToken)
{
    await Task.Delay(
        TimeSpan.FromMinutes(5),
        cancellationToken);

    return "Completed";
}

When the client calls:

cts.Cancel();

SignalR sends a cancellation message to the server.

The server-side token is then cancelled.

The important point is that cancellation is cooperative.

SignalR does not forcibly terminate arbitrary server code. The hub method and the operations it calls must observe the token.

Basic Client Example

Start with a SignalR connection:

var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/workhub")
    .Build();

await connection.StartAsync();

Create a cancellation token:

using var cancellationTokenSource =
    new CancellationTokenSource();

Then invoke the hub method:

var result = await connection.InvokeAsync<string>(
    "LongRunningWork",
    cancellationTokenSource.Token);

At some point, the client can cancel:

cancellationTokenSource.Cancel();

A complete example looks like this:

using var cts = new CancellationTokenSource();

try
{
    var result = await connection.InvokeAsync<string>(
        "LongRunningWork",
        cts.Token);

    Console.WriteLine(result);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Operation was cancelled.");
}

The OperationCanceledException handling is important because cancellation is normally represented through the .NET cancellation model.

Server-Side Hub Method

The hub method needs to accept a CancellationToken.

For example:

public class WorkHub : Hub
{
    public async Task<string> LongRunningWork(
        CancellationToken cancellationToken)
    {
        await Task.Delay(
            TimeSpan.FromMinutes(5),
            cancellationToken);

        return "Work completed.";
    }
}

The token is supplied by SignalR.

You do not need to manually create a CancellationTokenSource inside the hub method to receive the client cancellation signal.

SignalR manages the cancellation token associated with the invocation.

Cancellation Is Cooperative

This is one of the most important concepts to understand.

Calling:

cts.Cancel();

does not magically stop every operation running on the server.

The operation must observe the cancellation token.

This works:

public async Task ProcessAsync(
    CancellationToken cancellationToken)
{
    await Task.Delay(
        TimeSpan.FromMinutes(5),
        cancellationToken);
}

So does:

await database.ExecuteAsync(
    command,
    cancellationToken);

when the database API supports cancellation.

But this does not provide useful cancellation:

public async Task ProcessAsync(
    CancellationToken cancellationToken)
{
    await Task.Delay(
        TimeSpan.FromMinutes(5));
}

The token is available, but the operation ignores it.

The server therefore has no way to stop that particular delay early.

Propagating Cancellation Through Your Service Layer

A good production design passes the cancellation token through the complete call chain.

For example:

public class WorkHub : Hub
{
    private readonly ReportService reportService;

    public WorkHub(ReportService reportService)
    {
        this.reportService = reportService;
    }

    public async Task<ReportResult> GenerateReportAsync(
        ReportRequest request,
        CancellationToken cancellationToken)
    {
        return await reportService.GenerateAsync(
            request,
            cancellationToken);
    }
}

The service then passes the token to the repository:

public async Task<ReportResult> GenerateAsync(
    ReportRequest request,
    CancellationToken cancellationToken)
{
    var records = await repository.LoadAsync(
        request,
        cancellationToken);

    return reportBuilder.Build(records);
}

The repository can pass it to the database operation:

public async Task<List<Record>> LoadAsync(
    ReportRequest request,
    CancellationToken cancellationToken)
{
    return await dbContext.Records
        .Where(x => x.Date >= request.StartDate)
        .ToListAsync(cancellationToken);
}

Now cancellation can flow through the application:

SignalR Client
      |
      v
Hub CancellationToken
      |
      v
Service
      |
      v
Repository
      |
      v
EF Core
      |
      v
Database Operation

This is much more effective than checking the token only at the hub boundary.

Cancelling Multiple Operations

A long-running operation may perform several asynchronous tasks.

For example:

public async Task<ReportResult> GenerateAsync(
    CancellationToken cancellationToken)
{
    var customers =
        await LoadCustomersAsync(cancellationToken);

    var orders =
        await LoadOrdersAsync(cancellationToken);

    var products =
        await LoadProductsAsync(cancellationToken);

    return BuildReport(customers, orders, products);
}

Each operation receives the same token.

If the client cancels the SignalR invocation, all subsequent operations can observe the same cancellation request.

Checking Cancellation During CPU-Bound Work

Cancellation is not limited to asynchronous I/O.

Suppose an operation performs CPU-intensive processing:

public ReportResult BuildReport(
    IReadOnlyList<Record> records,
    CancellationToken cancellationToken)
{
    foreach (var record in records)
    {
        cancellationToken.ThrowIfCancellationRequested();

        ProcessRecord(record);
    }

    return CreateReport();
}

The important call is:

cancellationToken.ThrowIfCancellationRequested();

It allows the operation to stop at a controlled point.

For large collections, periodic cancellation checks can prevent unnecessary CPU consumption after the client has already abandoned the operation.

Using IsCancellationRequested

Instead of throwing immediately, code can inspect the token:

while (HasMoreWork())
{
    if (cancellationToken.IsCancellationRequested)
    {
        return;
    }

    ProcessNextItem();
}

Whether to throw or return normally depends on the application's API design.

For most asynchronous application workflows, ThrowIfCancellationRequested() provides clearer cancellation semantics.

Cancellation and Database Queries

Database operations are one of the most useful places to propagate cancellation.

With Entity Framework Core:

public async Task<List<Order>> GetOrdersAsync(
    int customerId,
    CancellationToken cancellationToken)
{
    return await dbContext.Orders
        .Where(order => order.CustomerId == customerId)
        .ToListAsync(cancellationToken);
}

If the user cancels the SignalR operation while the query is executing, the cancellation token can be propagated to EF Core.

The exact behavior after cancellation depends on the database provider and its support for cancelling the underlying database command.

The important design principle is to pass the token all the way down instead of stopping at the hub layer.

Cancellation and HTTP Requests

The same pattern applies when a hub method calls another API.

For example:

public async Task<string> GetExternalDataAsync(
    CancellationToken cancellationToken)
{
    return await httpClient.GetStringAsync(
        "api/data",
        cancellationToken);
}

Now the cancellation flow becomes:

SignalR Client
      |
      | Cancel
      v
Hub
      |
      v
HttpClient
      |
      v
External API Request

If the downstream HTTP operation supports cancellation, unnecessary network work can also be stopped.

Cancellation and Task.WhenAll

Suppose a hub operation performs several independent operations:

public async Task<DashboardData> LoadDashboardAsync(
    CancellationToken cancellationToken)
{
    var usersTask =
        LoadUsersAsync(cancellationToken);

    var ordersTask =
        LoadOrdersAsync(cancellationToken);

    var productsTask =
        LoadProductsAsync(cancellationToken);

    await Task.WhenAll(
        usersTask,
        ordersTask,
        productsTask);

    return new DashboardData
    {
        Users = await usersTask,
        Orders = await ordersTask,
        Products = await productsTask
    };
}

All operations share the same cancellation token.

If the client cancels the invocation, each operation has an opportunity to stop.

This is preferable to creating independent cancellation sources that are unaware of the SignalR invocation lifecycle.

Client-Side Timeout

Cancellation can also be used to implement a timeout.

For example:

using var cts =
    new CancellationTokenSource(
        TimeSpan.FromSeconds(30));

try
{
    var result = await connection.InvokeAsync<string>(
        "LongRunningWork",
        cts.Token);

    Console.WriteLine(result);
}
catch (OperationCanceledException)
{
    Console.WriteLine(
        "The operation exceeded the timeout.");
}

This gives the client control over how long it is willing to wait.

However, a timeout should be selected according to the operation.

A report-generation endpoint may legitimately need longer than a simple lookup.

User Cancellation vs Timeout

It is useful to distinguish between different reasons for cancellation.

For example:

using var userCancellation =
    new CancellationTokenSource();

using var timeoutCancellation =
    new CancellationTokenSource(
        TimeSpan.FromSeconds(30));

using var linkedCancellation =
    CancellationTokenSource.CreateLinkedTokenSource(
        userCancellation.Token,
        timeoutCancellation.Token);

The linked token can be passed to SignalR:

var result = await connection.InvokeAsync<string>(
    "LongRunningWork",
    linkedCancellation.Token);

Now either event can cancel the operation:

User clicks Cancel
       |
       +------+
              |
Timeout ------+----> Linked Token
                     |
                     v
               SignalR Cancel

For more complex applications, this allows user cancellation and application timeouts to share the same cancellation pipeline.

Cancellation and Exceptions

A cancelled operation commonly results in OperationCanceledException.

The client should handle cancellation separately from genuine failures.

For example:

try
{
    var result = await connection.InvokeAsync<string>(
        "LongRunningWork",
        cancellationToken);
}
catch (OperationCanceledException)
{
    Console.WriteLine(
        "The operation was cancelled.");
}
catch (HubException ex)
{
    Console.WriteLine(
        $"Hub error: {ex.Message}");
}

Avoid treating expected cancellation as an application failure.

For monitoring systems, cancellation should normally be tracked separately from unexpected exceptions.

What Happens on the Server?

The lifecycle is approximately:

1. Client invokes hub method
             |
             v
2. Server starts hub invocation
             |
             v
3. Hub receives CancellationToken
             |
             v
4. Client cancels token
             |
             v
5. SignalR sends cancellation message
             |
             v
6. Server cancellation token is triggered
             |
             v
7. Application observes cancellation
             |
             v
8. Operation stops
             |
             v
9. Invocation completes as cancelled

The important step is number seven.

SignalR can signal cancellation, but application code must cooperate with that signal.

Cancellation Does Not Kill the SignalR Connection

A major advantage of invocation cancellation is that it applies to the individual hub invocation.

Cancelling:

cts.Cancel();

does not mean:

await connection.StopAsync();

The SignalR connection remains available.

For example:

SignalR Connection
        |
        +--> Invocation A
        |       |
        |       +--> Cancelled
        |
        +--> Invocation B
        |       |
        |       +--> Continues
        |
        +--> Invocation C
                |
                +--> Continues

This is important for interactive applications.

A user can cancel one expensive operation without losing the real-time connection itself.

A Complete Example

The following example demonstrates a cancellable report-generation workflow.

Server

public class ReportHub : Hub
{
    private readonly ReportService reportService;

    public ReportHub(ReportService reportService)
    {
        this.reportService = reportService;
    }

    public async Task<ReportResult> GenerateReportAsync(
        ReportRequest request,
        CancellationToken cancellationToken)
    {
        return await reportService.GenerateAsync(
            request,
            cancellationToken);
    }
}

The service propagates the token:

public class ReportService
{
    private readonly ApplicationDbContext dbContext;

    public ReportService(ApplicationDbContext dbContext)
    {
        this.dbContext = dbContext;
    }

    public async Task<ReportResult> GenerateAsync(
        ReportRequest request,
        CancellationToken cancellationToken)
    {
        var records = await dbContext.Records
            .Where(x => x.Date >= request.StartDate)
            .Where(x => x.Date <= request.EndDate)
            .ToListAsync(cancellationToken);

        cancellationToken.ThrowIfCancellationRequested();

        return BuildReport(records, cancellationToken);
    }

    private ReportResult BuildReport(
        List<Record> records,
        CancellationToken cancellationToken)
    {
        foreach (var record in records)
        {
            cancellationToken.ThrowIfCancellationRequested();

            ProcessRecord(record);
        }

        return new ReportResult();
    }

    private void ProcessRecord(Record record)
    {
        // CPU-intensive processing.
    }
}

Client

using var cancellationTokenSource =
    new CancellationTokenSource();

try
{
    var report = await connection.InvokeAsync<ReportResult>(
        "GenerateReportAsync",
        request,
        cancellationTokenSource.Token);

    DisplayReport(report);
}
catch (OperationCanceledException)
{
    Console.WriteLine("Report generation cancelled.");
}

A Cancel button can trigger:

cancellationTokenSource.Cancel();

The SignalR connection remains available for future operations.

Common Mistakes

Forgetting the CancellationToken Parameter

This does not provide server-side cancellation:

public async Task<string> LongRunningWork()
{
    await DoWorkAsync();

    return "Done";
}

The hub method needs to accept a token:

public async Task<string> LongRunningWork(
    CancellationToken cancellationToken)
{
    await DoWorkAsync(cancellationToken);

    return "Done";
}

Ignoring the Token

This is another common mistake:

await Task.Delay(
    TimeSpan.FromMinutes(5));

Instead:

await Task.Delay(
    TimeSpan.FromMinutes(5),
    cancellationToken);

Creating a New Unrelated Token

Avoid replacing the SignalR token with an unrelated source:

using var cts =
    new CancellationTokenSource();

If the application needs an additional timeout or cancellation condition, combine it with the invocation token rather than ignoring the original token.

Cancelling the Connection Instead of the Invocation

Do not call:

await connection.StopAsync();

when the requirement is only to cancel one long-running operation.

Stopping the connection affects all active SignalR communication.

Assuming Cancellation Is Instant

Cancellation is cooperative.

The server may need to reach a cancellation-aware operation or cancellation checkpoint before the work actually stops.

Best Practices

For production SignalR applications:

  1. Accept a CancellationToken in long-running hub methods.

  2. Pass the token through service and repository layers.

  3. Use cancellation-aware database APIs.

  4. Pass the token to HttpClient operations.

  5. Check the token during CPU-intensive loops.

  6. Use ThrowIfCancellationRequested() where appropriate.

  7. Treat expected cancellation separately from unexpected exceptions.

  8. Use linked tokens when combining user cancellation with timeouts.

  9. Cancel individual invocations instead of terminating the SignalR connection.

  10. Test cancellation while the operation is waiting on I/O.

  11. Test cancellation during CPU-intensive processing.

  12. Verify that downstream services actually honor cancellation.

  13. Avoid logging normal cancellation as an application error.

  14. Do not assume cancellation guarantees immediate termination.

Advantages

Lower Resource Consumption

Cancelled operations can stop unnecessary CPU, database, network, and memory work.

Better User Experience

Users can cancel expensive operations without disconnecting from the real-time application.

Cleaner Application Architecture

The cancellation signal can flow naturally from the SignalR client through the hub, services, repositories, and external dependencies.

Better Scalability

Stopping abandoned work can free server resources for operations that users still need.

No Connection Restart

Cancelling an invocation does not require the SignalR connection to be closed and re-established.

Limitations

Cancellation Is Cooperative

SignalR cannot forcibly terminate arbitrary synchronous code.

Dependencies Must Support Cancellation

The benefit depends on whether database providers, HTTP clients, SDKs, and other dependencies honor cancellation tokens.

Existing Work May Need Cleanup

If an operation has already created temporary resources, the application may still need explicit cleanup logic.

Cancellation Is Not a Transaction Rollback

Cancelling an operation does not automatically undo changes that have already been committed.

For example:

Database Update 1
       |
       v
Database Update 2
       |
       v
Cancellation

The cancellation request does not automatically roll back Update 1.

Use transactions and appropriate consistency mechanisms when an operation requires atomic behavior.

Cancellation vs Disconnect

These scenarios should not be confused.

Scenario

Recommended Mechanism

User cancels one operation

CancellationToken

Operation exceeds timeout

Linked cancellation token

Network connection fails

SignalR reconnect handling

User logs out

Application authentication/logout flow

Server must terminate connection

Connection management

Long-running database operation should stop

Pass cancellation token to database API

External HTTP operation should stop

Pass cancellation token to HttpClient

Conclusion

.NET 11 makes SignalR cancellation considerably more useful by allowing a .NET client to cancel regular, non-streaming hub method invocations.

The programming model is straightforward:

var result = await connection.InvokeAsync<string>(
    "LongRunningWork",
    cancellationToken);

The hub accepts the corresponding token:

public async Task<string> LongRunningWork(
    CancellationToken cancellationToken)
{
    await DoWorkAsync(cancellationToken);

    return "Completed";
}

When the client cancels its token, SignalR communicates that cancellation to the server. The server-side token is triggered, and application code can stop its work cooperatively.

The real value comes from propagating that token beyond the hub:

Client
  |
  v
SignalR Invocation
  |
  v
Hub
  |
  v
Service
  |
  v
Repository
  |
  +--> Database
  |
  +--> HTTP API
  |
  +--> CPU-intensive processing

For applications that perform expensive or user-controlled operations, this provides a much better cancellation model than abandoning a client-side Task while allowing server work to continue.

The key principle is simple: cancellation should travel with the operation from the client all the way to the resources performing the work.