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);
}
This is the conventional .NET approach: cancellation is part of the operation contract and is explicitly propagated through async calls.
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 internal 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: Operation-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 Pattern Works Well
This pattern works best in internal application code where the cancellation token belongs to the current operation and most layers only forward it without making cancellation decisions themselves.
In an ASP.NET Core app, that operation is usually the HTTP request, and the token source is `HttpContext.RequestAborted`.
The pattern is especially useful when:
- your application services are internal, not public library APIs
- the token comes from a well-defined operation boundary, such as an HTTP request or message handler
- intermediate layers only pass the token along
- the token is ultimately needed by infrastructure calls, such as EF Core queries or outbound HTTP requests
The point is not to hide cancellation from code that cares about it. The point is to avoid forcing every intermediate method to accept a parameter it does not actually use.
Extending the Pattern Beyond HTTP Requests
Although this article uses ASP.NET Core as the main example, the idea is more general. It is better described as operation-scoped cancellation than request-scoped cancellation.
A message consumer can use the same pattern if the token comes from the message-processing context instead of HttpContext.RequestAborted:
public sealed class MessageCancellationTokenMiddleware
{
private readonly ICancellationTokenAccessor _cancellationTokenAccessor;
public MessageCancellationTokenMiddleware(
ICancellationTokenAccessor cancellationTokenAccessor)
{
_cancellationTokenAccessor = cancellationTokenAccessor;
}
public async Task InvokeAsync(
MessageContext context,
Func<Task> next)
{
using (_cancellationTokenAccessor.Use(context.CancellationToken))
{
await next();
}
}
}
The important rule is that the ambient token must belong to the current operation:
| Operation | Token source |
|---|---|
| HTTP request | HttpContext.RequestAborted |
| Message consumer | message-processing context token |
| Background job | job execution token |
| Hosted service | stoppingToken or a linked operation token |
What you should avoid is mixing scopes. Do not capture an HTTP request token and use it later for background work:
// Bad: HTTP request token escaping into background work
_ = Task.Run(() => ProcessLaterAsync(_cancellationTokenAccessor.Token));
If the work outlives the request, it needs a cancellation token that belongs to that later operation.
The Trade-Off: Ambient Context
This pattern does introduce ambient context. The token no longer appears in every method signature, so developers need to understand where it comes from and how it is scoped.
That is the trade-off: less boilerplate and cleaner application interfaces in exchange for a small amount of implicit infrastructure context.
AsyncLocal<T> flows with the logical async execution context. It is suitable for work that remains inside the same logical operation, such as an HTTP request, message handler, or job execution.
It is not a mechanism for carrying cancellation into unrelated future work. If you queue work, start fire-and-forget tasks, or store the token for later use outside the operation that created it, you can introduce subtle bugs.
The safe rule is simple: set the token at the operation boundary, use it inside that operation, and reset it when the operation ends.
Final Thoughts
Passing `CancellationToken` explicitly is the standard .NET approach, and it is often the right choice. But in many ASP.NET Core applications, the request cancellation token is mostly plumbing. It starts at the operation boundary and is only needed when the code reaches a cancellable I/O call.
An `AsyncLocal<T>`-based accessor gives you a practical middle ground. It keeps cancellation available without forcing every intermediate service method to accept a parameter it does not actually use.
Use it deliberately. Set the token at the operation boundary, keep it inside that operation, and reset it when the operation ends.

RSS - Posts