Most codebases I inherit treat CancellationToken as ceremony. It's in the signature because
the analyzer asked for it, it gets default passed at the first call site that felt like effort,
and somewhere down the stack a catch (Exception ex) turns a cancelled request into an
ERROR log line and a 500.
That's a shame, because cancellation is the cheapest throughput win available in an ASP.NET Core application. Not "cheap" as in micro-optimisation — cheap as in one parameter per method and no change to the logic in between.
This is how I use it across a multi-module line-of-business app: MVC controllers, raw ADO.NET repositories, a Graph-backed document client, health probes and a background ingestion job.
What cancellation actually buys you
A request that the user abandoned — closed tab, hit Escape, navigated away, lost the VPN — does not stop costing you anything. Without a token in the chain, the work runs to completion and the result is thrown into a socket nobody is reading.
What it holds while it does that:
| Resource | Held until | Pool size is finite |
|---|---|---|
| Thread-pool thread | Every await resumes |
Yes — growth is throttled |
| ADO.NET connection | The command completes | Yes — default max pool 100 |
| SQL Server worker + locks | The query finishes | Yes — and it blocks other sessions |
| Outbound socket to an upstream API | The response is fully read | Yes — per-endpoint concurrency |
| Response buffer / large object heap | The pipeline unwinds | Memory pressure, GC |
The last two rows are the interesting ones. Under load, the failure mode is almost never CPU.
It's a pool exhausting: connection timeouts, SocketException, requests queueing behind work
that already has no consumer. Cancellation is the only mechanism that reaches down into the
driver and takes those back early.
This is the argument for why I consider it the best-value performance tool in the stack: it doesn't make the happy path faster, it stops the unhappy path from consuming the capacity the happy path needs. Caching makes 80% of requests cheap; cancellation makes the remaining 20% stop hurting the 80%.
Rule one: the token comes from the request, and never stops
ASP.NET Core model-binds a CancellationToken parameter to HttpContext.RequestAborted. No
attribute, no registration — just declare it last:
[HttpGet]
public async Task<IActionResult> Folders(
[FromQuery] string? serverRelPath,
CancellationToken ct)
{
var folders = await _service.GetSubFoldersAsync(serverRelPath, ct);
return Json(new { ok = true, data = folders });
}
From there it is a mechanical, boring propagation. Service:
public async Task<IReadOnlyList<SpFolderItem>> ListSubFoldersAsync(
string? serverRelPath = null,
CancellationToken ct = default)
{
using var http = await BuildHttpAsync(ct);
var siteId = await GetSiteIdAsync(http, ct);
var driveId = await GetDriveIdAsync(http, siteId, ct);
// ...
}
Repository, down to the raw ADO.NET calls — every async call takes the overload, including
OpenAsync and ReadAsync:
public async Task<SnapshotInfo> GetSnapshotInfoAsync(CancellationToken ct = default)
{
await using var conn = new SqlConnection(_connectionString);
await conn.OpenAsync(ct);
await using var cmd = new SqlCommand(
"SELECT COUNT(*) AS N, MAX(DT_ACQ) AS DT FROM dbo.SNAPSHOT;", conn);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (await reader.ReadAsync(ct))
{
// ...
}
}
ExecuteReaderAsync(ct) is the line that matters. When the token fires, SqlClient sends an
attention packet to SQL Server, which aborts the running query server-side. You are not just
abandoning a Task on the client — you are telling the database to stop burning a worker and
release its locks. OpenAsync(ct) matters for the same reason at the other end: it stops you
from waiting on an exhausted connection pool for a request that no longer exists.
Miss the token on one call in the chain and the whole chain becomes uncancellable for the duration of that call. There is no partial credit.
Rule two: a timeout is just a cancellation
This is the part that made the pattern click for me. Once every method takes a token, you get deadlines for free — you don't need a second mechanism, a second exception type, or per-library timeout configuration.
A health probe that must answer in ten seconds or report degraded:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var sw = Stopwatch.StartNew();
try
{
using var res = await http.GetAsync(
url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
result.IsReachable = res.IsSuccessStatusCode;
result.ResponseTimeMs = (int)sw.ElapsedMilliseconds;
}
catch (OperationCanceledException)
{
result.IsReachable = false;
result.Error = "Probe timed out after 10s";
_logger.LogWarning("Health probe timed out");
}
Two details worth stealing:
HttpCompletionOption.ResponseHeadersRead— this is a reachability check, so the body is never read. Combined with the token, the socket goes back to the handler pool as soon as headers land, instead of after streaming a payload we're going to discard.- The
CancellationTokenSourceisusing. A CTS with a timer is an unmanaged-ish resource: not disposing it leaks the timer registration until it fires. This is the single most common leak I find in code that does use cancellation.
When a request-scoped token and a deadline both apply, link them — the result cancels on whichever comes first:
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var data = await _upstream.FetchAsync(linked.Token);
Note that HttpClient.Timeout does not compose like this. It's a per-client setting, it throws
TaskCanceledException rather than something you can distinguish easily, and it can't be
shortened for a single call. I set HttpClient.Timeout as a generous backstop (30s for Graph,
10s for token acquisition) and use linked tokens for the real, per-operation deadlines.
Rule three: shutdown is also just a cancellation
The same token flows through background work. BackgroundService hands you a stoppingToken
that fires when the host is shutting down:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
// Let the app finish starting before the first call.
await Task.Delay(TimeSpan.FromSeconds(_settings.InitialDelaySeconds), stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(_settings.IntervalMinutes));
do
{
await SyncOnceAsync(stoppingToken);
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
catch (OperationCanceledException)
{
// Normal shutdown. Not an error, not logged as one.
}
_logger.LogInformation("Sync service stopped");
}
PeriodicTimer.WaitForNextTickAsync(token) is the piece I wish I'd known about earlier. The old
await Task.Delay(interval, token) loop drifts (interval + work time) and needs its own
cancellation handling; PeriodicTimer ticks on a fixed schedule and the token ends the loop
cleanly.
And SyncOnceAsync passes it straight down into the HTTP fetch and the bulk write, so a shutdown
mid-sync aborts the in-flight query instead of making the host wait out its shutdown timeout and
then kill the process. That's the difference between a five-second deploy and a thirty-second one.
The four ways it silently does nothing
Every failure I've had with this pattern is one of these.
1. catch (Exception) above the token. Cancellation surfaces as
OperationCanceledException (or TaskCanceledException, which derives from it). A generic
handler turns a normal, expected, free outcome into an error log, an alert, and a 500 response
to a client that has already gone. Catch it first:
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// caller went away — nothing to report
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Transfer failed");
return StatusCode(500, new { ok = false });
}
The when (ct.IsCancellationRequested) filter matters: without it you also swallow an
OperationCanceledException thrown by a timeout you'd actually want to know about.
2. = default as a silent opt-out. Optional token parameters are ergonomic at the leaves and
dangerous in the middle. A single call site that forgets to pass ct compiles fine and silently
makes that subtree uncancellable. When I find a stubborn uncancellable path, this is the first
thing I grep for.
3. Cancelling after the point of no return. A token in a write path is only safe while the
work is still undoable. Inside a transaction I pass it to DELETE and to SqlBulkCopy, because
cancelling there rolls back and the previous snapshot survives intact. I would not pass a
request-scoped token to the commit itself, or to a call that has already produced an external
side effect — an email sent, a document filed. Bound the cancellable region to the part that
cancels cleanly.
4. Synchronous code. CancellationToken cannot interrupt a blocking call. cmd.ExecuteReader()
holds its thread until the server answers, token or not. If a path is CPU-bound or blocking, the
token only helps between the chunks, at explicit ct.ThrowIfCancellationRequested() checkpoints
you place yourself.
Why I think this is the highest-leverage thing you can do
Three reasons.
It's uniform. One concept covers user abandonment, per-operation deadlines, host shutdown, and
composite policies via linked sources. Every alternative — per-library timeout settings, watchdog
tasks, Thread.Abort-era thinking — solves one of those and not the others.
It reaches the resource, not just the Task. This is the part people miss. Abandoning a
Task frees nothing; the work continues. A token reaches the SqlClient driver, the socket, the
Graph call. The reclaim is real, and it happens at the moment of abandonment rather than at
completion.
It degrades gracefully under exactly the load where you need it. Cancellation does nothing on an idle system. Its value scales with queueing — which is to say, it is worth the most precisely when the system is closest to falling over. A timeout that fires at 10 seconds under load frees a connection that ten queued requests are waiting for. That's a compounding return no amount of query tuning gives you.
The cost is one parameter per method signature and the discipline never to drop it. I have yet to find a better ratio anywhere in the stack.
Checklist
- Declare
CancellationToken cton controller actions — MVC bindsHttpContext.RequestAborted - Pass it to every async call downstream, including
OpenAsyncandReadAsync usingeveryCancellationTokenSource— a timer CTS leaks if you don't- Use
CreateLinkedTokenSourceto combine a request token with a deadline - Catch
OperationCanceledExceptionbeforeException, and don't log it as an error - Use
PeriodicTimer.WaitForNextTickAsync(stoppingToken)inBackgroundServiceloops - Don't thread a token past the point where the work stops being undoable
- Remember it can't interrupt blocking code — add explicit
ThrowIfCancellationRequested()checkpoints
Code samples are simplified from a production ASP.NET Core application (MVC, raw ADO.NET, Microsoft Graph, hosted background services) and are idiomatic C# on .NET 8.