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 CancellationTokenSource is using. 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 ct on controller actions — MVC binds HttpContext.RequestAborted
  • Pass it to every async call downstream, including OpenAsync and ReadAsync
  • using every CancellationTokenSource — a timer CTS leaks if you don't
  • Use CreateLinkedTokenSource to combine a request token with a deadline
  • Catch OperationCanceledException before Exception, and don't log it as an error
  • Use PeriodicTimer.WaitForNextTickAsync(stoppingToken) in BackgroundService loops
  • 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.