SignalR is designed for real-time communication, but not every hub invocation completes quickly.
Some operations can take seconds or even minutes:
Generating a report
Processing a large dataset
Running an AI operation
Exporting files
Executing a long database operation
Calling an external service
Performing background-style calculations
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:
CPU
Database connections
Memory
Network resources
External API calls
Application capacity
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.
Join the conversation! Your thoughts help the community grow.