AL.
🇪🇸 ES
Back to blog
.NET & C# · 9 min read

CancellationToken in .NET: A Complete Guide with API Examples

Learn how to use CancellationToken in .NET APIs for better performance, graceful shutdowns, and responsive code with real-world examples.


You’ve probably seen CancellationToken parameters everywhere in .NET APIs and wondered if you really need them. The answer is yes—and not just for the sake of following conventions.

CancellationToken enables responsive applications by allowing long-running operations to be cancelled gracefully. It improves performance by preventing wasted work, enables better resource cleanup, and is essential for scenarios like user-initiated cancellation, timeouts, and application shutdown.

In this comprehensive guide, we’ll cover what CancellationToken is, how it works, and how to use it correctly in ASP.NET Core APIs, Entity Framework, HttpClient, and more.

What Is CancellationToken?

CancellationToken is a struct that propagates notification that operations should be cancelled. It’s designed to be lightweight and passed by value.

Think of it like a stop signal:

  • CancellationTokenSource: The control panel that can trigger cancellation
  • CancellationToken: The read-only token passed to operations that can be cancelled
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

// Later...
cts.Cancel(); // Triggers cancellation

When Cancel() is called, any code checking the token knows to stop.

Why CancellationToken Matters

1. User-Initiated Cancellation

Users close browser tabs, navigate away, or cancel operations. Without cancellation, your API continues processing requests no one is listening to:

// Without cancellation: wastes resources
[HttpGet("report")]
public async Task<IActionResult> GenerateReport()
{
    var data = await _dbContext.Orders.ToListAsync(); // Takes 30 seconds
    var report = ProcessReport(data); // Takes 10 seconds
    return Ok(report);
}

If the user navigates away after 5 seconds, your server still spends 35 seconds processing.

2. Timeouts

Some operations should have time limits:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var result = await LongRunningOperationAsync(cts.Token);

3. Graceful Shutdown

When your app shuts down, in-flight requests should complete gracefully or be cancelled cleanly.

Basic Usage: Creating and Checking Tokens

Creating a CancellationTokenSource

// No timeout
var cts = new CancellationTokenSource();

// With timeout
var ctsWithTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));

// Manual cancellation
cts.Cancel();

Checking for Cancellation

Three ways to check if cancellation was requested:

public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    // Method 1: ThrowIfCancellationRequested (throws OperationCanceledException)
    cancellationToken.ThrowIfCancellationRequested();

    // Method 2: Check IsCancellationRequested
    if (cancellationToken.IsCancellationRequested)
    {
        // Clean up and return
        return;
    }

    // Method 3: Register a callback
    cancellationToken.Register(() =>
    {
        Console.WriteLine("Cancellation requested!");
    });

    await Task.Delay(1000, cancellationToken); // Most async APIs accept CancellationToken
}

Using CancellationToken in ASP.NET Core APIs

ASP.NET Core automatically provides a CancellationToken that triggers when:

  1. The client disconnects
  2. The request times out
  3. The application is shutting down

Accessing the Request CancellationToken

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _context;

    public ProductsController(AppDbContext context)
    {
        _context = context;
    }

    // Automatically injected via parameter binding
    [HttpGet]
    public async Task<ActionResult<List<ProductDto>>> GetProducts(CancellationToken cancellationToken)
    {
        var products = await _context.Products
            .AsNoTracking()
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
            })
            .ToListAsync(cancellationToken); // Pass to EF Core

        return Ok(products);
    }

    // Alternative: Access via HttpContext
    [HttpGet("alternative")]
    public async Task<ActionResult<List<ProductDto>>> GetProductsAlternative()
    {
        var cancellationToken = HttpContext.RequestAborted;

        var products = await _context.Products
            .ToListAsync(cancellationToken);

        return Ok(products);
    }
}

What Happens When Cancelled?

When a client disconnects:

[HttpGet("long-running")]
public async Task<ActionResult> LongRunningOperation(CancellationToken cancellationToken)
{
    try
    {
        for (int i = 0; i < 100; i++)
        {
            // Check for cancellation
            cancellationToken.ThrowIfCancellationRequested();

            await ProcessChunkAsync(i, cancellationToken);
        }

        return Ok("Completed");
    }
    catch (OperationCanceledException)
    {
        // Client disconnected - clean up
        _logger.LogInformation("Request was cancelled by client");
        return StatusCode(499); // Client Closed Request (nginx convention)
    }
}

ASP.NET Core handles OperationCanceledException gracefully—it doesn’t log it as an error by default.

CancellationToken with Entity Framework Core

EF Core methods accept CancellationToken parameters:

public class OrderService
{
    private readonly AppDbContext _context;

    public OrderService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetOrderByIdAsync(int id, CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<List<Order>> GetRecentOrdersAsync(CancellationToken cancellationToken = default)
    {
        return await _context.Orders
            .Where(o => o.CreatedAt > DateTime.UtcNow.AddDays(-30))
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public async Task<int> CreateOrderAsync(Order order, CancellationToken cancellationToken = default)
    {
        _context.Orders.Add(order);
        return await _context.SaveChangesAsync(cancellationToken);
    }
}

When cancellation is triggered during a database operation:

  • EF Core aborts the query
  • Database connection is closed/returned to pool
  • No partial data is committed (unless you explicitly committed earlier)

CancellationToken with HttpClient

Always pass cancellation tokens to HTTP calls:

public class ExternalApiService
{
    private readonly HttpClient _httpClient;

    public ExternalApiService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<UserData?> GetUserAsync(int userId, CancellationToken cancellationToken = default)
    {
        try
        {
            var response = await _httpClient.GetStringAsync(
                $"https://api.example.com/users/{userId}",
                cancellationToken);

            return JsonSerializer.Deserialize<UserData>(response);
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("HTTP request cancelled for user {UserId}", userId);
            return null;
        }
        catch (HttpRequestException ex)
        {
            _logger.LogError(ex, "HTTP request failed for user {UserId}", userId);
            throw;
        }
    }

    public async Task<bool> UpdateUserAsync(int userId, UserData data, CancellationToken cancellationToken = default)
    {
        var json = JsonSerializer.Serialize(data);
        var content = new StringContent(json, Encoding.UTF8, "application/json");

        var response = await _httpClient.PostAsync(
            $"https://api.example.com/users/{userId}",
            content,
            cancellationToken);

        return response.IsSuccessStatusCode;
    }
}

If the cancellation token is triggered mid-request, the HTTP request is aborted and the connection is released.

Linked Tokens: Combining Multiple Cancellation Sources

Sometimes you need to cancel based on multiple conditions:

public async Task<Report> GenerateReportAsync(int reportId, CancellationToken userCancellationToken)
{
    // Create timeout token
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5));

    // Combine user cancellation + timeout
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        userCancellationToken,
        timeoutCts.Token);

    try
    {
        var data = await _repository.GetReportDataAsync(reportId, linkedCts.Token);
        return ProcessReport(data);
    }
    catch (OperationCanceledException)
    {
        if (timeoutCts.Token.IsCancellationRequested)
        {
            throw new TimeoutException("Report generation timed out");
        }

        // User cancelled
        throw;
    }
}

The linked token cancels if any of the source tokens cancel.

Timeout with CancelAfter

Set or modify timeout dynamically:

public async Task<string> FetchDataWithTimeoutAsync(string url)
{
    using var cts = new CancellationTokenSource();

    // Set timeout
    cts.CancelAfter(TimeSpan.FromSeconds(10));

    try
    {
        var response = await _httpClient.GetStringAsync(url, cts.Token);
        return response;
    }
    catch (OperationCanceledException)
    {
        throw new TimeoutException($"Request to {url} timed out");
    }
}

// Or extend timeout conditionally
public async Task<string> FetchDataWithDynamicTimeoutAsync(string url, bool isPriority)
{
    using var cts = new CancellationTokenSource();

    var timeout = isPriority ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(10);
    cts.CancelAfter(timeout);

    var response = await _httpClient.GetStringAsync(url, cts.Token);
    return response;
}

Graceful Shutdown with IHostApplicationLifetime

Handle application shutdown gracefully:

public class DataSyncService : BackgroundService
{
    private readonly IHostApplicationLifetime _appLifetime;
    private readonly ILogger<DataSyncService> _logger;

    public DataSyncService(IHostApplicationLifetime appLifetime, ILogger<DataSyncService> logger)
    {
        _appLifetime = appLifetime;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Register callbacks
        _appLifetime.ApplicationStarted.Register(() =>
            _logger.LogInformation("Application started"));

        _appLifetime.ApplicationStopping.Register(() =>
            _logger.LogInformation("Application stopping - cleaning up"));

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await SyncDataAsync(stoppingToken);
                await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
            }
            catch (OperationCanceledException)
            {
                _logger.LogInformation("Data sync cancelled - shutting down");
                break;
            }
        }

        _logger.LogInformation("Data sync service stopped");
    }

    private async Task SyncDataAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("Starting data sync");

        // Long-running work that respects cancellation
        var data = await FetchDataAsync(cancellationToken);
        await ProcessDataAsync(data, cancellationToken);

        _logger.LogInformation("Data sync completed");
    }
}

Common Patterns and Best Practices

Pattern 1: Default Parameter

Always provide a default value for cancellation tokens:

// Good: default parameter
public async Task<List<Order>> GetOrdersAsync(CancellationToken cancellationToken = default)
{
    return await _context.Orders.ToListAsync(cancellationToken);
}

// Usage
var orders = await service.GetOrdersAsync(); // Uses default (no cancellation)
var orders = await service.GetOrdersAsync(cts.Token); // Uses specific token

Pattern 2: Propagate Tokens Down the Call Stack

public async Task<Report> GenerateReportAsync(int id, CancellationToken cancellationToken = default)
{
    var data = await FetchDataAsync(id, cancellationToken); // Propagate
    var processed = await ProcessDataAsync(data, cancellationToken); // Propagate
    return await SaveReportAsync(processed, cancellationToken); // Propagate
}

Pattern 3: Don’t Create New Tokens Unnecessarily

// Bad: creates unnecessary token
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    using var cts = new CancellationTokenSource(); // Why?
    await SomeOperationAsync(cts.Token); // Should use the parameter!
}

// Good: use the provided token
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    await SomeOperationAsync(cancellationToken);
}

Only create new tokens when you need additional cancellation logic (timeouts, linked tokens).

Pattern 4: Dispose CancellationTokenSource

CancellationTokenSource is IDisposable:

// Good: using statement
public async Task<string> FetchWithTimeoutAsync(string url)
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    return await _httpClient.GetStringAsync(url, cts.Token);
}

// Or explicit disposal
var cts = new CancellationTokenSource();
try
{
    await DoWorkAsync(cts.Token);
}
finally
{
    cts.Dispose();
}

Anti-Patterns to Avoid

Anti-Pattern 1: Ignoring CancellationToken

// Bad: parameter exists but is never used
public async Task<List<Order>> GetOrdersAsync(CancellationToken cancellationToken = default)
{
    return await _context.Orders.ToListAsync(); // Missing token!
}

// Good: pass it through
public async Task<List<Order>> GetOrdersAsync(CancellationToken cancellationToken = default)
{
    return await _context.Orders.ToListAsync(cancellationToken);
}

Anti-Pattern 2: Swallowing OperationCanceledException

// Bad: swallows cancellation
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    try
    {
        await SomeOperationAsync(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        // Ignoring cancellation - caller thinks it succeeded!
    }
}

// Good: let it propagate or handle explicitly
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
    try
    {
        await SomeOperationAsync(cancellationToken);
    }
    catch (OperationCanceledException)
    {
        _logger.LogInformation("Operation cancelled");
        throw; // Re-throw so caller knows
    }
}

Anti-Pattern 3: Passing CancellationToken.None Explicitly

// Bad: explicitly passing None
await DoWorkAsync(CancellationToken.None);

// Good: omit parameter (defaults to default)
await DoWorkAsync();

CancellationToken.None and default(CancellationToken) are equivalent—prefer the cleaner syntax.

Real-World Example: File Upload with Progress and Cancellation

[ApiController]
[Route("api/[controller]")]
public class FileUploadController : ControllerBase
{
    private readonly IFileStorageService _fileStorage;

    [HttpPost]
    [RequestSizeLimit(100_000_000)] // 100 MB
    public async Task<ActionResult<UploadResult>> Upload(
        IFormFile file,
        CancellationToken cancellationToken)
    {
        if (file == null || file.Length == 0)
            return BadRequest("No file uploaded");

        try
        {
            var uploadId = Guid.NewGuid().ToString();

            await using var stream = file.OpenReadStream();

            var result = await _fileStorage.UploadAsync(
                uploadId,
                file.FileName,
                stream,
                cancellationToken);

            return Ok(new UploadResult
            {
                UploadId = uploadId,
                FileName = file.FileName,
                Size = file.Length
            });
        }
        catch (OperationCanceledException)
        {
            _logger.LogInformation("File upload cancelled");
            return StatusCode(499, "Upload cancelled");
        }
    }
}

public class FileStorageService : IFileStorageService
{
    public async Task<string> UploadAsync(
        string uploadId,
        string fileName,
        Stream stream,
        CancellationToken cancellationToken)
    {
        var buffer = new byte[81920]; // 80 KB chunks
        var totalRead = 0L;

        await using var fileStream = File.Create($"uploads/{uploadId}");

        while (true)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var bytesRead = await stream.ReadAsync(buffer, cancellationToken);
            if (bytesRead == 0) break;

            await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken);
            totalRead += bytesRead;
        }

        return uploadId;
    }
}

If the user cancels the upload:

  • The token triggers
  • File writing stops
  • Partial file can be cleaned up
  • Connection is released

Conclusion

CancellationToken is not optional ceremony—it’s essential for building responsive, efficient, and production-ready .NET applications.

Key takeaways:

  • Always accept CancellationToken in async methods (with = default)
  • Pass tokens down the call stack to all async APIs
  • Use ASP.NET Core’s automatic token for request cancellation
  • Combine tokens with CreateLinkedTokenSource for complex scenarios
  • Use CancelAfter for timeouts
  • Handle OperationCanceledException appropriately
  • Dispose CancellationTokenSource when creating it

Respect cancellation, and your applications will be more performant, responsive, and resilient.