Passing CancellationToken all the way down in ASP.NET Core
Why a cancelled HTTP request should stop its database query too, and the small habits that make it happen.
3 min read
On this page
Work nobody is waiting for
A user opens a report page, gets impatient, and navigates away. The browser closes the connection, but the server keeps going: the controller still waits on a heavy SQL query, maps thousands of rows, and serializes a response that nobody will read.
On a quiet day that is harmless. Under load, abandoned requests compete with real ones for
database connections and thread-pool time. ASP.NET Core already knows when the client is gone; it
exposes that through a CancellationToken. The work is to pass that token everywhere.
Where the token comes from
Add a CancellationToken parameter to an action and ASP.NET Core binds it to
HttpContext.RequestAborted automatically:
[HttpGet("reports/monthly")]
public async Task<IActionResult> Monthly(int year, int month, CancellationToken ct)
{
var report = await reports.BuildMonthlyAsync(year, month, ct);
return Ok(report);
}Minimal APIs work the same way:
app.MapGet("/reports/monthly", (int year, int month, IReportService reports, CancellationToken ct) =>
reports.BuildMonthlyAsync(year, month, ct));Pass it to everything that awaits
The token only helps if it reaches the code that actually waits. That usually means EF Core,
HttpClient, and any other I/O:
public sealed class ReportService(AppDbContext db, HttpClient rates) : IReportService
{
public async Task<MonthlyReport> BuildMonthlyAsync(int year, int month, CancellationToken ct)
{
var from = new DateTime(year, month, 1);
var to = from.AddMonths(1);
var premiums = await db.Payments
.Where(p => p.PaidAt >= from && p.PaidAt < to)
.GroupBy(p => p.ProductCode)
.Select(g => new { Product = g.Key, Total = g.Sum(p => p.Amount) })
.ToListAsync(ct);
var fx = await rates.GetFromJsonAsync<FxRates>("rates/latest", ct);
return MonthlyReport.Create(premiums, fx);
}
}When the token is cancelled, EF Core asks SQL Server to cancel the running command, and
HttpClient aborts the outgoing call. Both throw OperationCanceledException.
Simple rules that keep it consistent
- Every async method that does I/O takes a
CancellationToken, usually as the last parameter. Name itctorcancellationToken, and stay consistent within the codebase. - Do not default it with
= defaultin application code. A default parameter makes it easy to forget, and the compiler will no longer remind you. Libraries are the exception. - Never pass
CancellationToken.Nonejust to make it compile. If a call truly must not be cancelled, write a comment explaining why. - Loops check the token. For CPU-heavy loops call
ct.ThrowIfCancellationRequested()once per iteration or batch.
Things that should not be cancelled
Some work must finish even if the user leaves, such as writing an audit record or committing a payment that the provider already accepted. Do not pass the request token there:
// The provider has already charged the customer; recording it must not depend on the browser.
await payments.MarkAsPaidAsync(paymentId, CancellationToken.None);For longer work, hand it off to a background queue or a hosted service with its own lifetime, instead of running it inside the request.
Do not log cancellations as errors
A cancelled request is normal, not a bug. If your global error handling logs every exception as an error, abandoned requests will flood the logs. Filter them out when the client is gone:
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
// Client disconnected; nothing to report.
}Adding your own timeout
Sometimes you also want a hard limit, regardless of the client. Link both:
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
var result = await slowService.RunAsync(timeout.Token);The operation now stops when the client leaves or after ten seconds, whichever comes first.
Summary
- ASP.NET Core gives you
RequestAbortedfor free; take it as a parameter. - Pass it to EF Core,
HttpClient, and every awaited I/O call. - Deliberately skip it only for work that must complete, and say why.
- Treat cancellations as normal, not as errors in your logs.