What are the best practices for cancellation in async operations using `CancellationToken`?

4 minintermediate.NETCancellationTokenasync-awaitbest-practices

Quick Answer

`CancellationToken` provides cooperative cancellation: a `CancellationTokenSource` issues a token that methods accept and observe via `ThrowIfCancellationRequested()`, `IsCancellationRequested`, or by passing it to async APIs. Best practices: flow the token through the whole call chain, honor it promptly in loops/long work, propagate it to downstream calls, and handle the resulting `OperationCanceledException`. Cancellation is cooperative — code must check the token, it isn't forced.

Detailed Answer

CancellationToken provides a cooperative cancellation mechanism for async operations. Here are the best practices:

1. Always accept CancellationToken parameters:

// Good: Accepts cancellation token
public async Task FetchDataAsync(CancellationToken cancellationToken = default)
{
    return await _httpClient.GetFromJsonAsync(url, cancellationToken);
}

// Bad: No way to cancel
public async Task FetchDataAsync()
{
    return await _httpClient.GetFromJsonAsync(url);
}

2. Pass tokens through the call chain:

public async Task ProcessOrderAsync(Order order, CancellationToken cancellationToken)
{
    // Pass token to all async calls
    await ValidateOrderAsync(order, cancellationToken);
    await SaveOrderAsync(order, cancellationToken);
    await SendConfirmationAsync(order, cancellationToken);
}

private async Task ValidateOrderAsync(Order order, CancellationToken cancellationToken)
{
    await _validator.ValidateAsync(order, cancellationToken);
}

3. Check for cancellation in long-running operations:

public async Task ProcessItemsAsync(List items, CancellationToken cancellationToken)
{
    foreach (var item in items)
    {
        // Check before each iteration
        cancellationToken.ThrowIfCancellationRequested();
        
        await ProcessItemAsync(item, cancellationToken);
    }
}

4. Use timeout with CancellationTokenSource:

public async Task FetchWithTimeoutAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
    
    try
    {
        return await FetchDataAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        throw new TimeoutException("Operation timed out after 30 seconds");
    }
}

5. Link multiple cancellation tokens:

public async Task ProcessAsync(CancellationToken userToken)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        userToken, 
        timeoutCts.Token
    );
    
    // Cancelled if either user cancels OR timeout occurs
    await LongRunningOperationAsync(linkedCts.Token);
}

6. Handle OperationCanceledException appropriately:

public async Task TryProcessAsync(CancellationToken cancellationToken)
{
    try
    {
        var data = await FetchDataAsync(cancellationToken);
        return Result.Success(data);
    }
    catch (OperationCanceledException)
    {
        // Expected when cancelled - don't log as error
        _logger.LogInformation("Operation was cancelled");
        return Result.Cancelled();
    }
    catch (Exception ex)
    {
        // Unexpected exception - log as error
        _logger.LogError(ex, "Operation failed");
        return Result.Failed(ex.Message);
    }
}

7. Register cleanup actions:

public async Task ProcessWithCleanupAsync(CancellationToken cancellationToken)
{
    var resource = await AcquireResourceAsync();
    
    // Register cleanup when cancelled
    using var registration = cancellationToken.Register(() =>
    {
        _logger.LogInformation("Cancellation requested - cleaning up");
        resource.Dispose();
    });
    
    try
    {
        await ProcessResourceAsync(resource, cancellationToken);
    }
    finally
    {
        resource.Dispose();
    }
}

8. Use CancellationToken in ASP.NET Core:

[HttpGet]
public async Task GetData(CancellationToken cancellationToken)
{
    // Automatically cancelled if client disconnects
    var data = await _service.FetchDataAsync(cancellationToken);
    return Ok(data);
}

9. Provide meaningful default values:

// Use default parameter for optional cancellation
public async Task LoadDataAsync(CancellationToken cancellationToken = default)
{
    // Works with or without cancellation token
    await Task.Delay(1000, cancellationToken);
    return new Data();
}

10. Don't swallow OperationCanceledException unnecessarily:

// Bad: Hides cancellation
public async Task ProcessAsync(CancellationToken cancellationToken)
{
    try
    {
        await DoWorkAsync(cancellationToken);
    }
    catch (Exception ex) // Catches OperationCanceledException too
    {
        _logger.LogError(ex, "Error");
        // Cancellation is hidden
    }
}

// Good: Let OperationCanceledException propagate
public async Task ProcessAsync(CancellationToken cancellationToken)
{
    try
    {
        await DoWorkAsync(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        // Let it propagate or handle specifically
        throw;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Error");
        throw;
    }
}

Complete example:

public class DataService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger _logger;
    
    public async Task ProcessDataAsync(
        string url, 
        CancellationToken cancellationToken = default)
    {
        // Add timeout protection
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(TimeSpan.FromMinutes(5));
        
        try
        {
            // Pass token through call chain
            var data = await FetchDataAsync(url, cts.Token);
            var processed = await TransformDataAsync(data, cts.Token);
            await SaveDataAsync(processed, cts.Token);
            
            return ProcessingResult.Success();
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("Processing cancelled");
            return ProcessingResult.Cancelled();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Processing failed");
            return ProcessingResult.Failed(ex.Message);
        }
    }
    
    private async Task FetchDataAsync(string url, CancellationToken cancellationToken)
    {
        return await _httpClient.GetFromJsonAsync(url, cancellationToken);
    }
}