Cancellation is one of those features that is easy to ignore until it matters. When a user navigates away, a request times out, or a service shuts down, your code needs to stop doing unnecessary work. CancellationToken is the cooperative cancellation mechanism in .NET, and using it correctly makes your applications more responsive and resource-efficient.

The Basics

A CancellationTokenSource creates tokens. Code receiving the token periodically checks whether cancellation has been requested:

Example.cs
var cts = new CancellationTokenSource();

// Pass the token to async work
await DoWorkAsync(cts.Token);

// Cancel from another thread or after a timeout
cts.Cancel();

The word "cooperative" is key. Cancellation does not forcibly abort a thread. The code doing the work must check the token and decide to stop.

Always Pass Tokens Down

The most common mistake is accepting a CancellationToken parameter but not passing it to the methods you call:

Example.cs
// Bad — ignores cancellation in the HTTP call
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
    var response = await _httpClient.GetAsync($"/orders/{id}"); // No token!
    return await response.Content.ReadFromJsonAsync<Order>();
}

// Good — propagates cancellation to every async operation
public async Task<Order> GetOrderAsync(int id, CancellationToken ct)
{
    var response = await _httpClient.GetAsync($"/orders/{id}", ct);
    return await response.Content.ReadFromJsonAsync<Order>(ct);
}

Every async API in .NET accepts a CancellationToken. Pass it through.

Linking Tokens

Sometimes you need to combine multiple cancellation sources — for example, a request-scoped token and a timeout:

Example.cs
public async Task<Report> GenerateReportAsync(CancellationToken requestToken)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        requestToken, timeoutCts.Token);

    return await BuildReportAsync(linkedCts.Token);
}

The linked token fires if either the request is cancelled or the timeout expires. Always dispose linked token sources to avoid memory leaks.

Handling OperationCanceledException

When a token is cancelled, most framework methods throw OperationCanceledException (or its subclass TaskCanceledException). Handle it appropriately:

Example.cs
public async Task<IActionResult> GetData(CancellationToken ct)
{
    try
    {
        var data = await _service.FetchDataAsync(ct);
        return Ok(data);
    }
    catch (OperationCanceledException) when (ct.IsCancellationRequested)
    {
        // Client disconnected — log and return gracefully
        _logger.LogInformation("Request cancelled by client");
        return StatusCode(499); // Client Closed Request
    }
}

The when filter is important. It distinguishes between "our caller cancelled" and "something else threw an OperationCanceledException for an unrelated reason."

Timeouts with CancellationTokenSource

The constructor overload that accepts a TimeSpan is the cleanest way to implement timeouts:

Example.cs
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

try
{
    await LongRunningOperationAsync(cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    throw new TimeoutException("Operation did not complete within 10 seconds");
}

Checking the Token in CPU-Bound Loops

For CPU-bound work, call ThrowIfCancellationRequested periodically:

Example.cs
public void ProcessLargeDataSet(IList<Record> records, CancellationToken ct)
{
    for (int i = 0; i < records.Count; i++)
    {
        ct.ThrowIfCancellationRequested();

        ProcessRecord(records[i]);
    }
}

For very tight loops, check every N iterations to avoid the overhead of checking on every cycle:

Example.cs
for (int i = 0; i < records.Count; i++)
{
    if (i % 1000 == 0)
        ct.ThrowIfCancellationRequested();

    ProcessRecord(records[i]);
}

Registering Callbacks

You can register a callback that fires when cancellation is requested. This is useful for cleaning up unmanaged resources or cancelling non-token-aware APIs:

Example.cs
public async Task StreamDataAsync(Stream stream, CancellationToken ct)
{
    ct.Register(() => stream.Close()); // Force-close the stream on cancellation

    var buffer = new byte[4096];
    int bytesRead;
    while ((bytesRead = await stream.ReadAsync(buffer, ct)) > 0)
    {
        await ProcessChunkAsync(buffer.AsMemory(0, bytesRead));
    }
}

ASP.NET Core and HttpContext.RequestAborted

ASP.NET Core automatically provides a CancellationToken through HttpContext.RequestAborted. When using [FromServices] or parameter binding, you can accept it directly in controller actions:

Example.cs
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id, CancellationToken cancellationToken)
{
    // This token fires when the client disconnects
    var result = await _repository.GetByIdAsync(id, cancellationToken);
    return Ok(result);
}

This is automatic model binding — ASP.NET Core sees the CancellationToken parameter and binds HttpContext.RequestAborted to it. No configuration needed.

Dispose Your CancellationTokenSources

CancellationTokenSource implements IDisposable. If you create one, dispose it:

Example.cs
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await DoWorkAsync(cts.Token);

Failing to dispose can leak timer resources (when using the timeout constructor) and registered callbacks. The using declaration makes this effortless.