DEV_NET_CORE
GET_STARTED
.NETAsync programming, tasks, cancellation, and concurrency

CancellationToken Propagation

Overview

CancellationToken propagation is the habit of accepting a cancellation signal at the entry point of an operation and passing that same signal through every layer that performs asynchronous, long-running, or potentially expensive work.

In C#, cancellation is cooperative. A caller can request cancellation, but the running code must choose to observe the request and stop safely. This is different from forcibly killing a thread. The token is only a signal; it does not automatically stop work unless the code being executed checks it or passes it to APIs that check it.

This topic matters because modern .NET applications often perform I/O-heavy work: database queries, HTTP calls, file operations, queue processing, background jobs, streaming, and cloud service calls. If cancellation is ignored, an application can continue doing useless work after a user disconnects, a request times out, a background service is stopping, or a client no longer needs the result.

In interviews, CancellationToken questions test whether a developer understands practical async programming, resource usage, ASP.NET Core request handling, EF Core queries, HttpClient calls, graceful shutdown, and production reliability. A strong answer shows that you know not only how to add a token parameter, but also when to propagate it, when to check it, when not to cancel, and how to handle cancellation correctly.

Core Concepts

What a CancellationToken Is

A CancellationToken is a lightweight value type that represents a cancellation request. It is passed to code that may need to stop before finishing.

The token itself does not start or stop work. It only exposes cancellation state through members such as:

  • IsCancellationRequested
  • ThrowIfCancellationRequested()
  • Register(...)
  • CanBeCanceled

A token is normally produced by a CancellationTokenSource.

Code
using var cts = new CancellationTokenSource();

CancellationToken token = cts.Token;

Task work = DoWorkAsync(token);

cts.Cancel();

await work;

The important distinction is:

  • CancellationTokenSource owns the cancellation request.
  • CancellationToken observes the cancellation request.

Code that starts or controls the operation usually owns the CancellationTokenSource. Code that performs the operation usually receives only the CancellationToken.

What Propagation Means

Propagation means passing the same cancellation token from the outer operation into inner operations.

A common ASP.NET Core example:

Code
app.MapGet("/orders/{id:int}", async (
    int id,
    CancellationToken cancellationToken,
    IOrderService orderService) =>
{
    OrderDto order = await orderService.GetOrderAsync(id, cancellationToken);
    return Results.Ok(order);
});

Then the service passes the same token to the repository:

Code
public sealed class OrderService
{
    private readonly IOrderRepository _orders;

    public OrderService(IOrderRepository orders)
    {
        _orders = orders;
    }

    public async Task<OrderDto> GetOrderAsync(
        int id,
        CancellationToken cancellationToken)
    {
        Order order = await _orders.GetByIdAsync(id, cancellationToken);

        return new OrderDto
        {
            Id = order.Id,
            Number = order.Number,
            Total = order.Total
        };
    }
}

Then the repository passes it to EF Core:

Code
public sealed class OrderRepository
{
    private readonly AppDbContext _dbContext;

    public OrderRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Order> GetByIdAsync(
        int id,
        CancellationToken cancellationToken)
    {
        return await _dbContext.Orders
            .AsNoTracking()
            .SingleAsync(o => o.Id == id, cancellationToken);
    }
}

This is proper propagation because the original request cancellation signal reaches the database query.

A poor implementation accepts a token but stops passing it:

Code
public async Task<Order> GetByIdAsync(int id, CancellationToken cancellationToken)
{
    // Bad: token is ignored.
    return await _dbContext.Orders.SingleAsync(o => o.Id == id);
}

This compiles, but the cancellation signal is lost.

Cooperative Cancellation

Cancellation in .NET is cooperative. The caller requests cancellation, and the running operation decides how to respond.

For I/O-bound work, pass the token into APIs that support it:

Code
HttpResponseMessage response = await httpClient.SendAsync(
    request,
    cancellationToken);
Code
List<Customer> customers = await dbContext.Customers
    .Where(c => c.IsActive)
    .ToListAsync(cancellationToken);
Code
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);

For CPU-bound work, check the token manually inside loops or between expensive steps:

Code
public void GenerateReport(IEnumerable<Order> orders, CancellationToken cancellationToken)
{
    foreach (Order order in orders)
    {
        cancellationToken.ThrowIfCancellationRequested();

        ProcessOrder(order);
    }
}

Do not check the token on every tiny operation if it creates unnecessary overhead. Check it at reasonable boundaries: before starting work, inside long loops, before expensive calls, and between major processing steps.

CancellationTokenSource

CancellationTokenSource is the object used to trigger cancellation.

Code
using var cts = new CancellationTokenSource();

Task task = LongRunningOperationAsync(cts.Token);

cts.Cancel();

await task;

It can also be configured with a timeout:

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

await LongRunningOperationAsync(cts.Token);

Or:

Code
using var cts = new CancellationTokenSource();

cts.CancelAfter(TimeSpan.FromSeconds(10));

await LongRunningOperationAsync(cts.Token);

A CancellationTokenSource should be disposed when you own it, especially if it uses timers, registrations, or linked tokens.

Code
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await SomeOperationAsync(cts.Token);

Linked Cancellation Tokens

Sometimes an operation must stop for more than one reason. For example:

  • The HTTP client disconnected.
  • The server-side operation exceeded an internal timeout.
  • The application is shutting down.

A linked token combines multiple cancellation tokens into one token.

Code
public async Task<OrderDto> GetOrderWithTimeoutAsync(
    int id,
    CancellationToken requestCancellationToken)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));

    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        requestCancellationToken,
        timeoutCts.Token);

    return await GetOrderAsync(id, linkedCts.Token);
}

The linked token is canceled if either source token is canceled.

This is useful, but it should not be overused. Creating token sources has cost, and linked sources must be disposed. In most methods, simply accept and pass the token you received.

ASP.NET Core Request Cancellation

In ASP.NET Core, a request has a cancellation token exposed through HttpContext.RequestAborted. Minimal APIs and controller actions can accept a CancellationToken parameter, which represents the request-aborted signal.

Code
[ApiController]
[Route("api/orders")]
public sealed class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;

    public OrdersController(IOrderService orders)
    {
        _orders = orders;
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> Get(
        int id,
        CancellationToken cancellationToken)
    {
        OrderDto order = await _orders.GetOrderAsync(id, cancellationToken);
        return Ok(order);
    }
}

When the client disconnects or the request is aborted, the token is canceled. If that token is propagated to EF Core, HttpClient, file operations, or other async work, those operations may stop earlier and release resources.

This is especially important for:

  • Long-running API requests
  • Database queries
  • External API calls
  • File downloads and uploads
  • Streaming endpoints
  • Report generation
  • Search endpoints
  • Queue-triggered work that calls other services

EF Core and CancellationToken

EF Core async execution methods usually accept a cancellation token. Examples include:

Code
await dbContext.SaveChangesAsync(cancellationToken);
Code
Customer? customer = await dbContext.Customers
    .AsNoTracking()
    .FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
Code
List<Customer> customers = await dbContext.Customers
    .Where(c => c.IsActive)
    .OrderBy(c => c.Name)
    .ToListAsync(cancellationToken);

Important distinction:

  • Query-building methods such as Where, Select, and OrderBy usually do not execute the query.
  • Terminal async methods such as ToListAsync, SingleAsync, FirstOrDefaultAsync, and SaveChangesAsync execute database I/O and accept cancellation tokens.

A common mistake is building a query with a token in mind but forgetting to pass the token to the terminal operation.

Code
// Bad: cancellationToken is not used by the database operation.
var customers = await dbContext.Customers
    .Where(c => c.IsActive)
    .ToListAsync();
Code
// Good.
var customers = await dbContext.Customers
    .Where(c => c.IsActive)
    .ToListAsync(cancellationToken);

Database providers may differ in how completely they honor cancellation. Even so, passing the token is still the correct pattern because it gives the provider and underlying driver the opportunity to cancel.

HttpClient and External Calls

HttpClient methods support cancellation tokens. Propagating the token prevents the server from waiting unnecessarily on an outbound HTTP call after the original caller no longer needs the result.

Code
public async Task<ProductDto?> GetProductAsync(
    int id,
    CancellationToken cancellationToken)
{
    using var request = new HttpRequestMessage(
        HttpMethod.Get,
        $"https://example.com/products/{id}");

    using HttpResponseMessage response = await _httpClient.SendAsync(
        request,
        cancellationToken);

    if (response.StatusCode == HttpStatusCode.NotFound)
    {
        return null;
    }

    response.EnsureSuccessStatusCode();

    return await response.Content.ReadFromJsonAsync<ProductDto>(
        cancellationToken);
}

For streaming or large responses, cancellation is even more important because reading the response body may take time.

Code
using HttpResponseMessage response = await _httpClient.SendAsync(
    request,
    HttpCompletionOption.ResponseHeadersRead,
    cancellationToken);

await using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);

OperationCanceledException and TaskCanceledException

When cancellation is observed, .NET code usually throws OperationCanceledException. TaskCanceledException derives from OperationCanceledException and is often seen with task-based asynchronous operations.

Use ThrowIfCancellationRequested() when your own code detects cancellation:

Code
public async Task ImportAsync(
    IReadOnlyList<CustomerImportRow> rows,
    CancellationToken cancellationToken)
{
    foreach (CustomerImportRow row in rows)
    {
        cancellationToken.ThrowIfCancellationRequested();

        await ValidateRowAsync(row, cancellationToken);
        await SaveRowAsync(row, cancellationToken);
    }
}

Do not treat normal cancellation as an application error.

Code
try
{
    await service.DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    // Normal cancellation path.
    // Usually do not log this as an error.
}

In application code, cancellation is often logged at debug or information level, not error level, unless it indicates an unexpected timeout or operational problem.

Method Signature Conventions

A common convention is to place CancellationToken as the last parameter.

Code
Task<OrderDto> GetOrderAsync(int id, CancellationToken cancellationToken);

For public reusable APIs, it is common to provide a default value:

Code
public Task<OrderDto> GetOrderAsync(
    int id,
    CancellationToken cancellationToken = default)
{
    // ...
}

For internal application layers, many teams prefer requiring the token explicitly so developers do not accidentally ignore propagation:

Code
Task<OrderDto> GetOrderAsync(int id, CancellationToken cancellationToken);

For ASP.NET Core, MediatR handlers, hosted services, repositories, and services, passing the token explicitly is usually a good habit.

CancellationToken.None and default

CancellationToken.None and default both represent a token that will never be canceled.

Code
await DoWorkAsync(CancellationToken.None);

Use them only when cancellation is intentionally unavailable or not needed. Avoid using them inside lower-level methods when a caller already provided a real token.

Bad:

Code
public async Task SendEmailAsync(
    EmailMessage message,
    CancellationToken cancellationToken)
{
    // Bad: discards the caller's token.
    await _httpClient.PostAsJsonAsync("/email", message, CancellationToken.None);
}

Good:

Code
public async Task SendEmailAsync(
    EmailMessage message,
    CancellationToken cancellationToken)
{
    await _httpClient.PostAsJsonAsync("/email", message, cancellationToken);
}

Do Not Create New Tokens in Every Layer

A lower-level method should usually not create its own CancellationTokenSource just because it needs a token. It should accept a token from the caller.

Bad:

Code
public async Task<List<Order>> GetOrdersAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

    return await _dbContext.Orders.ToListAsync(cts.Token);
}

This hides timeout behavior inside the repository and prevents the caller from controlling cancellation consistently.

Better:

Code
public async Task<List<Order>> GetOrdersAsync(
    CancellationToken cancellationToken)
{
    return await _dbContext.Orders.ToListAsync(cancellationToken);
}

If a method needs an internal timeout, combine the caller token with a timeout using a linked token source.

Code
public async Task<List<Order>> GetOrdersAsync(
    CancellationToken cancellationToken)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));

    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken,
        timeoutCts.Token);

    return await _dbContext.Orders.ToListAsync(linkedCts.Token);
}

Point of No Cancellation

Cancellation should be safe. There are cases where cancellation should no longer be honored after an operation has passed a point of no cancellation.

Examples:

  • A payment has already been submitted.
  • A message has already been published and must be recorded.
  • A database transaction is being committed.
  • A file has been partially written and must be finalized or cleaned up.
  • An audit record must be written even if the user disconnected.

Before the point of no cancellation, observe and propagate the token. After that point, either complete the operation or perform compensating cleanup.

Example:

Code
public async Task PlaceOrderAsync(
    PlaceOrderRequest request,
    CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    await ValidateOrderAsync(request, cancellationToken);

    await using var transaction = await _dbContext.Database
        .BeginTransactionAsync(cancellationToken);

    Order order = CreateOrder(request);

    _dbContext.Orders.Add(order);

    await _dbContext.SaveChangesAsync(cancellationToken);

    // Point of no cancellation:
    // After payment is charged, the system must finish recording the result.
    PaymentResult payment = await _paymentGateway.ChargeAsync(
        order.PaymentDetails,
        cancellationToken);

    order.MarkPaid(payment.TransactionId);

    // Depending on business rules, this may intentionally use CancellationToken.None
    // so the local consistency update is not abandoned after payment succeeds.
    await _dbContext.SaveChangesAsync(CancellationToken.None);

    await transaction.CommitAsync(CancellationToken.None);
}

This decision depends on business requirements. In interviews, the key point is that cancellation must not leave the system in an inconsistent state.

Cancellation in Background Services

In BackgroundService, the ExecuteAsync method receives a stopping token. This token is canceled when the host is shutting down.

Code
public sealed class OrderWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderWorker> _logger;

    public OrderWorker(
        IServiceScopeFactory scopeFactory,
        ILogger<OrderWorker> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using IServiceScope scope = _scopeFactory.CreateScope();

            var processor = scope.ServiceProvider
                .GetRequiredService<IOrderProcessor>();

            await processor.ProcessNextBatchAsync(stoppingToken);

            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

Important habits:

  • Pass stoppingToken to all async work.
  • Pass it to Task.Delay.
  • Avoid swallowing OperationCanceledException as an error during shutdown.
  • Keep shutdown paths graceful and idempotent.

Cancellation in Async Streams

For IAsyncEnumerable<T>, cancellation can be passed into async iteration.

Code
await foreach (OrderDto order in GetOrdersAsync(cancellationToken)
    .WithCancellation(cancellationToken))
{
    Console.WriteLine(order.Number);
}

When writing an async iterator, use [EnumeratorCancellation] to connect the caller's cancellation token to the generated async enumerator.

Code
using System.Runtime.CompilerServices;

public async IAsyncEnumerable<OrderDto> StreamOrdersAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    for (int page = 1; page <= 10; page++)
    {
        cancellationToken.ThrowIfCancellationRequested();

        IReadOnlyList<OrderDto> orders = await LoadPageAsync(
            page,
            cancellationToken);

        foreach (OrderDto order in orders)
        {
            yield return order;
        }
    }
}

This matters for streaming APIs, large result sets, real-time feeds, and background processing.

Cancellation with Task.Run and Parallel Work

Passing a token to Task.Run can prevent the task from starting if cancellation is already requested, but it does not automatically stop the delegate once it is running. The delegate must still observe the token.

Code
Task task = Task.Run(() =>
{
    for (int i = 0; i < 1_000_000; i++)
    {
        cancellationToken.ThrowIfCancellationRequested();

        DoCpuWork(i);
    }
}, cancellationToken);

For multiple concurrent operations, pass the same token to each operation.

Code
await Task.WhenAll(
    SyncCustomersAsync(cancellationToken),
    SyncOrdersAsync(cancellationToken),
    SyncInvoicesAsync(cancellationToken));

Task.WhenAll does not add cancellation by itself. The individual tasks must observe the token.

API Design: Optional vs Required CancellationToken

There are two common styles.

Public library style:

Code
public Task<IReadOnlyList<Customer>> SearchAsync(
    string keyword,
    CancellationToken cancellationToken = default);

Application-internal style:

Code
Task<IReadOnlyList<Customer>> SearchAsync(
    string keyword,
    CancellationToken cancellationToken);

The public library style is convenient because callers can ignore cancellation if they do not need it. The application-internal style is stricter because it forces propagation through service, repository, and handler layers.

In production codebases, consistency matters more than personal preference. Teams should define a clear convention.

Common Mistakes

A frequent mistake is accepting a token but not passing it down.

Code
public async Task<List<Order>> SearchAsync(
    string keyword,
    CancellationToken cancellationToken)
{
    return await _dbContext.Orders
        .Where(o => o.Number.Contains(keyword))
        .ToListAsync(); // Token forgotten.
}

Another mistake is catching all exceptions and accidentally converting cancellation into a failure.

Code
try
{
    await DoWorkAsync(cancellationToken);
}
catch (Exception ex)
{
    // Bad: this also catches OperationCanceledException.
    _logger.LogError(ex, "Work failed.");
    throw;
}

Better:

Code
try
{
    await DoWorkAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
    _logger.LogInformation("Work was canceled.");
    throw;
}
catch (Exception ex)
{
    _logger.LogError(ex, "Work failed.");
    throw;
}

Another mistake is creating unrelated timeouts deep in the call stack without linking them to the caller token.

Code
// Bad: caller cancellation is ignored.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _externalApi.CallAsync(cts.Token);

Better:

Code
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
    cancellationToken,
    timeoutCts.Token);

await _externalApi.CallAsync(linkedCts.Token);

Another mistake is using cancellation to hide business errors. Cancellation should represent an operation that was requested to stop, not invalid input, failed validation, authorization failure, or a domain rule violation.

Best Practices

Use these habits in real projects:

  • Accept CancellationToken in async methods that perform I/O, long-running work, or loops.
  • Put CancellationToken as the last parameter.
  • Propagate the same token to EF Core, HttpClient, file APIs, queue clients, and other async APIs.
  • Check the token manually in CPU-bound loops.
  • Use ThrowIfCancellationRequested() when cancellation should stop the operation by throwing.
  • Dispose CancellationTokenSource instances that you create.
  • Use linked token sources when combining caller cancellation with internal timeouts.
  • Avoid CancellationToken.None unless cancellation is intentionally not supported at that point.
  • Do not log expected cancellation as an application error.
  • Think about the point of no cancellation before writes, commits, payments, messages, or other side effects.
  • Test cancellation behavior for long-running operations and shutdown paths.

Comparison: CancellationToken vs Timeout

A timeout is one reason to cancel. A cancellation token is the mechanism used to communicate cancellation.

Code
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

await DownloadAsync(timeoutCts.Token);

A token can represent:

  • User cancellation
  • Browser/client disconnect
  • Request timeout
  • Host shutdown
  • Manual operation cancellation
  • Combined cancellation reasons through linked tokens

A timeout is time-based. A token is signal-based.

Comparison: CancellationToken vs Thread.Abort

CancellationToken is cooperative and safe. The running operation decides when and how to stop.

Thread.Abort was a forceful mechanism that could interrupt execution unpredictably and leave state inconsistent. Modern .NET code should use cooperative cancellation instead.

Comparison: Cancellation vs Exception Handling

Cancellation commonly uses exceptions, but semantically it is not the same as an unexpected failure.

OperationCanceledException usually means: "The operation stopped because cancellation was requested."

A normal exception usually means: "The operation failed unexpectedly or could not complete successfully."

This difference affects logging, metrics, retries, and HTTP response behavior.

Interview Practice

PreviousAsync and Await SemanticsNext UpCoordinating Multiple Tasks in C#