CancellationToken Best Practices in ASP.NET Core
One of the strengths of ASP.NET Core is how naturally it supports cooperative cancellation. When a client disconnects, refreshes the page, closes the browser tab, or a request timeout is triggered, ASP.NET Core exposes that signal through HttpContext.RequestAborted.
You can access the same request cancellation token in a controller or Minimal API endpoint by accepting a CancellationToken parameter:
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var forecast = await _weatherService.GetForecastAsync(cancellationToken);
return Ok(forecast);
}
That is the standard approach, and in many cases it is exactly what you should do.
But in larger applications, this pattern can become noisy. The controller passes the token to a service. The service passes it to another service. That service passes it to a repository. The repository passes it to Entity Framework, HttpClient, or another I/O operation. Soon, CancellationToken appears in nearly every method signature, even when cancellation is not part of the business logic.
This article shows a cleaner ASP.NET Core pattern: store the current request cancellation token once at the edge of the request pipeline, then access it where it is actually needed.
TL;DR
For public APIs, reusable libraries, and operations where cancellation is part of the contract, pass CancellationToken explicitly.
For request-scoped ASP.NET Core application code, where the token only needs to reach occasional I/O calls, an AsyncLocal<T>-based accessor can reduce boilerplate and keep your service interfaces focused on business concerns.
The goal is not to hide cancellation everywhere. The goal is to avoid passing a technical plumbing parameter through layers that do not care about it.
Why CancellationToken Matters in ASP.NET Core
A CancellationToken does not forcibly stop your code. It is a cooperative signal. Code that receives the token can decide to stop early, throw OperationCanceledException, or pass the token to another cancellable API.
In ASP.NET Core, the most common request-level cancellation source is HttpContext.RequestAborted. This token is cancelled when the underlying HTTP request is aborted.
That matters because APIs often perform work that becomes useless once the client is gone:
- database queries
- outbound HTTP requests
- file operations
- long-running calculations
- expensive aggregation logic
If the request has already been abandoned, continuing that work wastes CPU, memory, database connections, HTTP sockets, and time.
So yes, you usually want cancellation support.
The question is how to add it without making every interface in your application look like infrastructure plumbing.
The Problem with Passing CancellationToken Everywhere
Consider a simple API endpoint:
[HttpGet]
public async Task<IActionResult> Get(CancellationToken cancellationToken)
{
var forecast = await _weatherService.GetForecastAsync(cancellationToken);
return Ok(forecast);
}
At first, this looks fine. But then GetForecastAsync calls another method:
public Task<IReadOnlyList<Forecast>> GetForecastAsync(CancellationToken cancellationToken)
{
return _forecastProvider.LoadForecastAsync(cancellationToken);
}
Then the provider calls a repository:
public Task<IReadOnlyList<Forecast>> LoadForecastAsync(CancellationToken cancellationToken)
{
return _forecastRepository.QueryForecastAsync(cancellationToken);
}
Then the repository finally uses it:
public async Task<IReadOnlyList<Forecast>> QueryForecastAsync(CancellationToken cancellationToken)
{
return await _dbContext.Forecasts
.AsNoTracking()
.ToListAsync(cancellationToken);
}Only the final method actually uses the token. The layers above it merely forward it.
That forwarding is not always wrong. Explicit dependencies are usually good. But when CancellationToken is only an implementation detail for lower-level I/O, passing it through every method creates a few problems:
- method signatures become noisier
- unit tests need to pass tokens even when they are irrelevant
- application services expose infrastructure concerns
- refactoring becomes more tedious
- business logic becomes visually harder to scan
This is the kind of code pollution we want to avoid.
A Cleaner Pattern: Request-Scoped Cancellation Accessor
Instead of threading the token through every method, you can capture it once in middleware and expose it through a small accessor.
The important detail is that the value must be scoped to the current asynchronous request flow. That is exactly what AsyncLocal<T> is designed for.
Here is a production-friendly version of the pattern.
Step 1: Create a CancellationToken Accessor
public interface ICancellationTokenAccessor
{
CancellationToken Token { get; }
IDisposable Use(CancellationToken cancellationToken);
}
The accessor exposes the current token and provides a scoped way to set it.
public sealed class CancellationTokenAccessor : ICancellationTokenAccessor
{
private readonly AsyncLocal<CancellationToken?> _current = new();
public CancellationToken Token => _current.Value ?? CancellationToken.None;
public IDisposable Use(CancellationToken cancellationToken)
{
var previous = _current.Value;
_current.Value = cancellationToken;
return new ResetCurrentToken(this, previous);
}
private sealed class ResetCurrentToken : IDisposable
{
private readonly CancellationTokenAccessor _accessor;
private readonly CancellationToken? _previous;
public ResetCurrentToken(
CancellationTokenAccessor accessor,
CancellationToken? previous)
{
_accessor = accessor;
_previous = previous;
}
public void Dispose()
{
_accessor._current.Value = _previous;
}
}
}The Use method matters. It lets middleware set the token for the current request and reset it afterward. That makes the lifetime explicit and avoids accidentally leaking state into later async work.
Step 2: Register the Accessor
Register the accessor once in Program.cs:
builder.Services.AddSingleton<ICancellationTokenAccessor, CancellationTokenAccessor>();Using a singleton is fine here because the actual value is not stored as ordinary instance state. It is stored in AsyncLocal<T>, which flows with the current asynchronous execution context.
Step 3: Capture RequestAborted in Middleware
Now add middleware that stores HttpContext.RequestAborted at the beginning of the request pipeline:
public sealed class RequestCancellationTokenMiddleware
{
private readonly RequestDelegate _next;
public RequestCancellationTokenMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(
HttpContext context,
ICancellationTokenAccessor cancellationTokenAccessor)
{
using (cancellationTokenAccessor.Use(context.RequestAborted))
{
await _next(context);
}
}
}Then register the middleware:
app.UseMiddleware<RequestCancellationTokenMiddleware>();Place it early enough in the pipeline so downstream services can access the token.
Now, in any service that’s part of the request scope, we can inject the ICancellationTokenAccessor and retrieve the token whenever we need it:
Step 4: Use the Token Only Where It Is Needed
Now your business-facing service method no longer needs a token parameter:
public interface IWeatherService
{
Task<IReadOnlyList<Forecast>> GetForecastAsync();
}
The implementation can use the accessor at the point where cancellation actually matters:
public sealed class WeatherService : IWeatherService
{
private readonly WeatherDbContext _dbContext;
private readonly ICancellationTokenAccessor _cancellationTokenAccessor;
public WeatherService(
WeatherDbContext dbContext,
ICancellationTokenAccessor cancellationTokenAccessor)
{
_dbContext = dbContext;
_cancellationTokenAccessor = cancellationTokenAccessor;
}
public async Task<IReadOnlyList<Forecast>> GetForecastAsync()
{
return await _dbContext.Forecasts
.AsNoTracking()
.ToListAsync(_cancellationTokenAccessor.Token);
}
}The controller becomes simple again:
[HttpGet]
public async Task<IActionResult> Get()
{
var forecast = await _weatherService.GetForecastAsync();
return Ok(forecast);
}The service interface describes what the application does, not how ASP.NET Core cancellation plumbing is propagated.
Example with HttpClient
The same pattern works well for outbound HTTP calls:
public sealed class WeatherApiClient
{
private readonly HttpClient _httpClient;
private readonly ICancellationTokenAccessor _cancellationTokenAccessor;
public WeatherApiClient(
HttpClient httpClient,
ICancellationTokenAccessor cancellationTokenAccessor)
{
_httpClient = httpClient;
_cancellationTokenAccessor = cancellationTokenAccessor;
}
public async Task<Forecast?> GetForecastAsync(string city)
{
return await _httpClient.GetFromJsonAsync<Forecast>(
$"/weather/{city}",
_cancellationTokenAccessor.Token);
}
}
Again, the token appears only at the boundary where it is useful.
When This Makes Sense – and When It Doesn’t
This approach is particularly useful in large applications where service methods are layered deeply and the cancellation token is only occasionally needed. Instead of threading the token through every call, you centralize its management and keep your interfaces focused on business concerns.
However, this does introduce a form of ambient context, which can obscure where the token is coming from. For developers unfamiliar with the pattern, it may not be immediately clear how cancellation works without some explanation. But this is a trade-off – one that favors reducing boilerplate and improving code readability, especially in services that are already deeply nested.
It’s also worth noting that AsyncLocal flows only within the logical execution context. If you queue work on a background thread or store the token for later use outside the scope of the request, you risk unexpected behavior or outright bugs. This pattern is safe for anything that runs asynchronously within the same request, but avoid using it for anything that lives beyond that boundary.
Final Thoughts
Passing CancellationToken through every method is the standard, and in many cases, the correct approach. But for request-scoped flows where you want cancellation support without constantly threading tokens through unrelated parts of the code, this pattern provides a cleaner alternative. It’s a small investment that can lead to more maintainable code, and fewer method signatures that look like they’ve been to baggage claim.

RSS - Posts