CancellationToken

Intro

CancellationToken is the standard .NET mechanism for cooperative cancellation. It lets callers request a stop while callees decide safe cancellation points and cleanup behavior. Correct token propagation is one of the biggest quality differences between toy async code and production-grade services — without it, canceled requests continue consuming resources long after the client has disconnected.

The model is cooperative: the caller signals intent to cancel via a CancellationTokenSource; the callee checks the token at safe points and throws OperationCanceledException to unwind cleanly.

How It Works

// Caller side: create a source and pass its token
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await DoWorkAsync(cts.Token);

// Or cancel manually
cts.Cancel();
// Callee side: accept and propagate the token
public async Task<OrderDto?> GetOrderAsync(
    int id,
    CancellationToken cancellationToken)
{
    // 1) Outbound HTTP call is cancellable.
    using var response = await _httpClient.GetAsync(
        $"orders/{id}",
        cancellationToken);

    response.EnsureSuccessStatusCode();

    // 2) JSON deserialization is also cancellable.
    return await response.Content.ReadFromJsonAsync<OrderDto>(
        cancellationToken: cancellationToken);
}

What happens when canceled:

  1. Caller calls cts.Cancel() or the timeout fires.
  2. GetAsync observes the token and throws OperationCanceledException.
  3. The exception propagates up the call stack.
  4. Callers treat it as expected control flow (not an error), typically logging at Debug level.

In ASP.NET Core, HttpContext.RequestAborted is a pre-wired token that fires when the client disconnects. Pass it to every downstream call:

public async Task<IActionResult> GetOrder(int id)
{
    var order = await _service.GetOrderAsync(id, HttpContext.RequestAborted);
    return Ok(order);
}

CPU-Bound Cancellation

For CPU-bound loops, check the token explicitly:

public async Task ProcessItemsAsync(
    IEnumerable<Item> items,
    CancellationToken cancellationToken)
{
    foreach (var item in items)
    {
        cancellationToken.ThrowIfCancellationRequested();
        await ProcessOneAsync(item, cancellationToken);
    }
}

ThrowIfCancellationRequested() is a cheap check — it reads a volatile bool. Call it at the top of each loop iteration for responsive cancellation.

Pitfalls

Accepting a token but not forwarding it
The most common mistake: the method signature accepts CancellationToken but passes CancellationToken.None (or nothing) to downstream calls. Cancellation is silently disabled.

// Bug: token accepted but not forwarded
public async Task<Data> LoadAsync(CancellationToken cancellationToken)
{
    return await _repo.GetAsync(id); // missing cancellationToken
}

Fix: forward the token to every downstream async call that accepts one. Code review should flag any async call without a token argument.

Swallowing OperationCanceledException
Catching Exception and not re-throwing OperationCanceledException makes canceled work look successful. Downstream code may act on a partial result.

// Bug: cancellation is hidden
try { return await DoWorkAsync(ct); }
catch (Exception ex) { _logger.LogError(ex, "Failed"); return null; }

Fix: catch OperationCanceledException separately and re-throw (or return a sentinel that callers understand as canceled):

try { return await DoWorkAsync(ct); }
catch (OperationCanceledException) { throw; } // re-throw, don't swallow
catch (Exception ex) { _logger.LogError(ex, "Failed"); return null; }

Using CancellationToken.None inside request flow
Hardcoding CancellationToken.None in a method called from a request pipeline breaks request-abort propagation. The operation continues even after the client disconnects, wasting resources.

Not disposing CancellationTokenSource
CancellationTokenSource implements IDisposable. Forgetting to dispose it leaks a timer registration when a timeout is set.

// Correct: dispose via using
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

Canceling at the wrong granularity
Canceling a shared CancellationTokenSource that multiple operations depend on cancels all of them. Use CancellationTokenSource.CreateLinkedTokenSource to create a child token that can be canceled independently.

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    parentToken, localTimeoutToken);
await DoWorkAsync(linkedCts.Token);

Questions

References


Whats next