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:
IsCancellationRequestedThrowIfCancellationRequested()Register(...)CanBeCanceled
A token is normally produced by a CancellationTokenSource.
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task work = DoWorkAsync(token);
cts.Cancel();
await work;
The important distinction is:
CancellationTokenSourceowns the cancellation request.CancellationTokenobserves 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:
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:
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:
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:
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:
HttpResponseMessage response = await httpClient.SendAsync(
request,
cancellationToken);
List<Customer> customers = await dbContext.Customers
.Where(c => c.IsActive)
.ToListAsync(cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
For CPU-bound work, check the token manually inside loops or between expensive steps:
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.
using var cts = new CancellationTokenSource();
Task task = LongRunningOperationAsync(cts.Token);
cts.Cancel();
await task;
It can also be configured with a timeout:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await LongRunningOperationAsync(cts.Token);
Or:
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.
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.
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.
[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:
await dbContext.SaveChangesAsync(cancellationToken);
Customer? customer = await dbContext.Customers
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
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, andOrderByusually do not execute the query. - Terminal async methods such as
ToListAsync,SingleAsync,FirstOrDefaultAsync, andSaveChangesAsyncexecute 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.
// Bad: cancellationToken is not used by the database operation.
var customers = await dbContext.Customers
.Where(c => c.IsActive)
.ToListAsync();
// 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.
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.
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:
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.
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.
Task<OrderDto> GetOrderAsync(int id, CancellationToken cancellationToken);
For public reusable APIs, it is common to provide a default value:
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:
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.
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:
public async Task SendEmailAsync(
EmailMessage message,
CancellationToken cancellationToken)
{
// Bad: discards the caller's token.
await _httpClient.PostAsJsonAsync("/email", message, CancellationToken.None);
}
Good:
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:
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:
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.
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:
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.
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
stoppingTokento all async work. - Pass it to
Task.Delay. - Avoid swallowing
OperationCanceledExceptionas an error during shutdown. - Keep shutdown paths graceful and idempotent.
Cancellation in Async Streams
For IAsyncEnumerable<T>, cancellation can be passed into async iteration.
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.
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.
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.
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:
public Task<IReadOnlyList<Customer>> SearchAsync(
string keyword,
CancellationToken cancellationToken = default);
Application-internal style:
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.
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.
try
{
await DoWorkAsync(cancellationToken);
}
catch (Exception ex)
{
// Bad: this also catches OperationCanceledException.
_logger.LogError(ex, "Work failed.");
throw;
}
Better:
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.
// Bad: caller cancellation is ignored.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _externalApi.CallAsync(cts.Token);
Better:
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
CancellationTokenin async methods that perform I/O, long-running work, or loops. - Put
CancellationTokenas 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
CancellationTokenSourceinstances that you create. - Use linked token sources when combining caller cancellation with internal timeouts.
- Avoid
CancellationToken.Noneunless 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.
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.