TL;DR:
- ASP.NET Core cancels requests when the client disconnects or times out.
- Use
HttpContext.RequestAborted
and pass it through to services and EF Core/database calls. - Proper cancellation avoids wasted CPU, memory leaks, and long-running queries.
- Always propagate the cancellation token from controller to database for graceful shutdown.
Ever clicked the stop button while waiting for a web page to load? What actually happens on the server when you do that?
In ASP.NET Core apps, without proper cancellation handling, the server keeps working on requests nobody wants anymore. Your server might be burning CPU cycles and holding database connections open for users who have already given up and gone elsewhere.
In this post, I’ll show you how to build a cancellation system that works all the way through your application stack, from the initial HTTP request down to your database queries.
Why Cancellation Matters
When users bail on requests, several real problems happen if your code doesn’t handle it well:
- Your server wastes time on work nobody needs
- Database connections stay open unnecessarily
- Your app handles fewer concurrent users
- Everyone gets a slower experience
Let’s fix these issues with some practical code.
How Cancellation Flows Through Your App
In ASP.NET Core, cancellation signals need to travel through several layers:
- User action (closing browser tab, clicking stop)
- HTTP pipeline (through HttpContext.RequestAborted)
- Controller action
- Service layer
- Repository/data access layer
- Database operations

Cancellation Signal Flow: From Browser User Action to Database Operations in ASP.NET Core
The diagram above shows how cancellation signals flow through your ASP.NET Core application. When a user cancels their request (by closing a tab or clicking stop), the signal propagates from the browser through each layer of your application stack.
For this to work right, each layer needs to pass the cancellation token to the next one.
HttpContext.RequestAborted: Where It All Starts
The HttpContext.RequestAborted
token is the key to knowing when a user has abandoned ship. This token automatically gets triggered when someone closes their tab or hits stop.
Here’s a simple controller using it:
[HttpGet("weather-forecast")]
public async Task<IActionResult> GetForecast()
{
// Using HttpContext.RequestAborted directly
var forecast = await _weatherService.GetForecastAsync(HttpContext.RequestAborted);
return Ok(forecast);
}
Linking Cancellation Tokens for More Control
Sometimes you need to cancel operations for different reasons, maybe the user gave up, or maybe an operation is just taking too long. That’s where linked tokens come in handy.
Linked tokens are like an “OR” condition, if any source cancels, the linked token cancels too.

Linked Cancellation Tokens: Combining Timeout and Request Cancellation Sources
This diagram demonstrates how linked cancellation tokens work. When either the timeout occurs or the client disconnects, the linked token triggers cancellation of the operation. The controller then handles the exception differently depending on which token triggered the cancellation.
Here’s how to combine a timeout with the browser’s cancellation signal:
[HttpGet("weather-forecast-with-timeout")]
public async Task<IActionResult> GetForecastWithTimeout()
{
// Create a token source for a 5-second timeout
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// Link the timeout with the request cancellation
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
timeoutCts.Token,
HttpContext.RequestAborted);
try
{
var forecast = await _weatherService.GetForecastAsync(linkedCts.Token);
return Ok(forecast);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
return StatusCode(408, "Request timed out");
}
catch (OperationCanceledException)
{
// Client disconnected, no need to send a response
return new EmptyResult();
}
}
Passing Cancellation Through Your Services
ASP.NET Core creates scoped services once per request, which makes them perfect for working with cancellation tokens that are tied to that specific request.
Here’s how to properly pass cancellation through your service layer:
public class WeatherService : IWeatherService
{
private readonly IWeatherRepository _repository;
public WeatherService(IWeatherRepository repository)
{
_repository = repository;
}
public async Task<WeatherForecast[]> GetForecastAsync(CancellationToken cancellationToken)
{
// Pass the token to the repository layer
var data = await _repository.GetForecastDataAsync(cancellationToken);
// Operations can check for cancellation between CPU-intensive work
cancellationToken.ThrowIfCancellationRequested();
// Process the data
return ProcessForecastData(data);
}
}
Database Layer: Cancelling SQL Operations
The repository layer is where your code talks to the database. Modern .NET database providers can actually cancel in-flight SQL queries when asked, which is pretty neat.

Database Cancellation: How Token Propagation Cancels In-Flight SQL Queries
The diagram above illustrates how cancellation propagates all the way to the database. When a cancellation token is passed through each layer down to Entity Framework Core, it can actually terminate the in-flight SQL query on the database server, freeing up resources immediately instead of waiting for the query to complete.
public class WeatherRepository : IWeatherRepository
{
private readonly ApplicationDbContext _dbContext;
public WeatherRepository(ApplicationDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<IEnumerable<WeatherData>> GetForecastDataAsync(
CancellationToken cancellationToken)
{
// EF Core operations accept cancellation tokens
return await _dbContext.WeatherData
.Where(d => d.Date >= DateTime.Today)
.OrderBy(d => d.Date)
.ToListAsync(cancellationToken);
}
}
Getting Entity Framework to Cancel Queries
Entity Framework Core already knows how to handle cancellation, you just need to pass the token to methods like ToListAsync()
or FirstOrDefaultAsync()
.
If you’re writing raw SQL with ADO.NET, it supports cancellation too:
public async Task<IEnumerable<WeatherData>> GetForecastDataWithAdoNetAsync(
CancellationToken cancellationToken)
{
using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(cancellationToken);
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM WeatherData WHERE Date >= @Today ORDER BY Date";
command.Parameters.AddWithValue("@Today", DateTime.Today);
// Set the cancellation token on the command
using var reader = await command.ExecuteReaderAsync(cancellationToken);
var results = new List<WeatherData>();
while (await reader.ReadAsync(cancellationToken))
{
results.Add(new WeatherData
{
Date = reader.GetDateTime(0),
Temperature = reader.GetInt32(1),
Summary = reader.GetString(2)
});
}
return results;
}
Writing Middleware That Handles Cancellation
You can also build middleware that knows when requests are canceled. This is great for operations that happen for every request:
public class CancellationAwareMiddleware
{
private readonly RequestDelegate _next;
public CancellationAwareMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
// Register a callback to know when cancellation occurs
context.RequestAborted.Register(() =>
Console.WriteLine($"Request {context.TraceIdentifier} was canceled"));
// Continue down the pipeline with the same token
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// Log the cancellation but don't try to modify the response
// as the client has disconnected
Console.WriteLine($"Request {context.TraceIdentifier} processing canceled");
// Re-throw to skip remaining middleware
throw;
}
}
}
// Extension method for easier registration
public static class CancellationAwareMiddlewareExtensions
{
public static IApplicationBuilder UseCancellationAware(
this IApplicationBuilder builder)
{
return builder.UseMiddleware<CancellationAwareMiddleware>();
}
}
Putting It All Together
Let’s see a complete example of a weather forecast API that handles cancellation properly from top to bottom:

Cancellation-Aware Architecture: Component Relationships for Token Propagation
This class diagram shows the relationships between the components in our cancellation-aware weather forecast API. The cancellation token is passed down through each layer, from the controller through the service and repository layers, all the way to the database operations.
// Controller
[ApiController]
[Route("api/[controller]")]
public class WeatherForecastController : ControllerBase
{
private readonly IWeatherService _weatherService;
public WeatherForecastController(IWeatherService weatherService)
{
_weatherService = weatherService;
}
[HttpGet]
public async Task<IActionResult> Get()
{
try
{
// Pass the request cancellation token to the service
var forecasts = await _weatherService.GetForecastAsync(HttpContext.RequestAborted);
return Ok(forecasts);
}
catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Request was canceled by the client, no need to return anything
return new EmptyResult();
}
}
[HttpGet("with-timeout/{seconds}")]
public async Task<IActionResult> GetWithTimeout(int seconds)
{
// Create a linked token that will cancel if either:
// 1. The client disconnects
// 2. The specified timeout is reached
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(seconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
HttpContext.RequestAborted,
timeoutCts.Token);
try
{
var forecasts = await _weatherService.GetForecastAsync(linkedCts.Token);
return Ok(forecasts);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
return StatusCode(408, "Request processing timed out");
}
catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Client disconnected, no need to send a response
return new EmptyResult();
}
}
}
Cancellation Comparison Table
Here’s how different approaches to cancellation compare:
Approach | Benefits | Drawbacks |
---|---|---|
No cancellation handling | Simplest code | Wastes resources, can hang your app |
HttpContext.RequestAborted only | Simple, handles client disconnects | Doesn’t support timeouts or app-specific cancellation |
Linked cancellation tokens | Flexible, combines multiple cancellation sources | Slightly more complex, need to manage CancellationTokenSource instances |
Custom timeout per operation | Fine-grained control | Need to tune timeouts for each operation type |
Testing Your Cancellation Code
You need to make sure your cancellation actually works. Here’s a simple test that checks if your repository respects cancellation:
[Fact]
public async Task Repository_RespectsTokenCancellation()
{
// Arrange
var repository = new WeatherRepository(_dbContext);
var cts = new CancellationTokenSource();
// Start an async task using the token
var task = repository.GetForecastDataAsync(cts.Token);
// Cancel the operation
cts.Cancel();
// Act & Assert
await Assert.ThrowsAsync<OperationCanceledException>(
async () => await task);
}
Common Mistakes to Watch Out For
Let me walk you through each mistake with examples of what not to do and how to fix it:
1. Forgetting to Pass the Token to Methods That Support It
This is probably the most common mistake. You have a cancellation token, but you forget to pass it to async methods that support it.
Wrong:
public async Task<WeatherData[]> GetWeatherAsync(CancellationToken cancellationToken)
{
// Oops! Not passing the token to HttpClient
var response = await _httpClient.GetAsync("https://api.weather.com/forecast");
// Also not passing it to EF Core
var data = await _dbContext.WeatherData.ToListAsync();
return ProcessData(data);
}
Right:
public async Task<WeatherData[]> GetWeatherAsync(CancellationToken cancellationToken)
{
// Pass the token to HttpClient
var response = await _httpClient.GetAsync("https://api.weather.com/forecast", cancellationToken);
// Pass the token to EF Core
var data = await _dbContext.WeatherData.ToListAsync(cancellationToken);
return ProcessData(data);
}
2. Catching OperationCanceledException But Not Checking Which Token Triggered It
When you have multiple cancellation sources (like timeout + request cancellation), you need to know which one triggered the cancellation to respond appropriately.
Wrong:
public async Task<IActionResult> GetDataWithTimeout()
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
HttpContext.RequestAborted, timeoutCts.Token);
try
{
var data = await _service.GetDataAsync(linkedCts.Token);
return Ok(data);
}
catch (OperationCanceledException)
{
// Which token was canceled? We don't know!
return StatusCode(408, "Something was canceled");
}
}
Right:
public async Task<IActionResult> GetDataWithTimeout()
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
HttpContext.RequestAborted, timeoutCts.Token);
try
{
var data = await _service.GetDataAsync(linkedCts.Token);
return Ok(data);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
return StatusCode(408, "Request timed out after 10 seconds");
}
catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
// Client disconnected, no need to send response
return new EmptyResult();
}
}
3. Making New Tokens When You Should Pass the Existing One
Creating new CancellationTokenSource
instances when you should be passing through the existing token breaks the cancellation chain.
Wrong:
public async Task<string> ProcessDataAsync(CancellationToken cancellationToken)
{
// Creating a new token instead of using the passed one
using var newCts = new CancellationTokenSource();
// This won't be canceled when the original token is canceled!
var result = await _externalService.FetchDataAsync(newCts.Token);
return result;
}
Right:
public async Task<string> ProcessDataAsync(CancellationToken cancellationToken)
{
// Use the passed token directly
var result = await _externalService.FetchDataAsync(cancellationToken);
return result;
}
// Or if you need to add a timeout to the existing token:
public async Task<string> ProcessDataWithTimeoutAsync(CancellationToken cancellationToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
var result = await _externalService.FetchDataAsync(combinedCts.Token);
return result;
}
4. Not Disposing CancellationTokenSource Objects
CancellationTokenSource
implements IDisposable
and should be disposed to free up resources, especially when creating many of them.
Wrong:
public async Task<IActionResult> GetDataWithMultipleTimeouts()
{
var quickTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var slowTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
// Not disposing these - memory leak!
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
HttpContext.RequestAborted, quickTimeout.Token, slowTimeout.Token);
var data = await _service.GetDataAsync(linkedCts.Token);
return Ok(data);
}
Right:
public async Task<IActionResult> GetDataWithMultipleTimeouts()
{
using var quickTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var slowTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
HttpContext.RequestAborted, quickTimeout.Token, slowTimeout.Token);
try
{
var data = await _service.GetDataAsync(linkedCts.Token);
return Ok(data);
}
catch (OperationCanceledException) when (HttpContext.RequestAborted.IsCancellationRequested)
{
return new EmptyResult();
}
catch (OperationCanceledException)
{
return StatusCode(408, "Request timed out");
}
}
5. Long CPU Work That Never Checks for Cancellation
Heavy CPU-bound operations that don’t check for cancellation can’t be interrupted, defeating the purpose of having a cancellation token.
Wrong:
public async Task<int[]> ProcessLargeDatasetAsync(int[] data, CancellationToken cancellationToken)
{
var result = new int[data.Length];
// This loop could run for minutes without checking cancellation
for (int i = 0; i < data.Length; i++)
{
// Expensive computation
result[i] = ComplexCalculation(data[i]);
}
return result;
}
Right:
public async Task<int[]> ProcessLargeDatasetAsync(int[] data, CancellationToken cancellationToken)
{
var result = new int[data.Length];
for (int i = 0; i < data.Length; i++)
{
// Check for cancellation periodically
cancellationToken.ThrowIfCancellationRequested();
// Or for really tight loops, check every N iterations
if (i % 1000 == 0)
{
cancellationToken.ThrowIfCancellationRequested();
}
result[i] = ComplexCalculation(data[i]);
}
return result;
}
// For parallel work, use the cancellation token with PLINQ
public async Task<int[]> ProcessLargeDatasetParallelAsync(int[] data, CancellationToken cancellationToken)
{
return await Task.Run(() =>
data.AsParallel()
.WithCancellation(cancellationToken)
.Select(ComplexCalculation)
.ToArray(),
cancellationToken);
}
Bonus Mistake: Ignoring Cancellation in Custom Async Methods
When writing your own async methods, make sure they actually support cancellation throughout their execution.
Wrong:
public async Task<string> CustomAsyncOperation(CancellationToken cancellationToken)
{
// Method signature accepts the token but doesn't use it
await Task.Delay(5000); // This won't be canceled!
return "Done";
}
Right:
public async Task<string> CustomAsyncOperation(CancellationToken cancellationToken)
{
// Pass the token to async operations
await Task.Delay(5000, cancellationToken);
return "Done";
}
Quick Checklist for Cancellation
Here’s a handy checklist to review your code:
- All async method calls pass the cancellation token
- CPU-intensive loops call
ThrowIfCancellationRequested()
periodically - Exception handling distinguishes between different cancellation sources
- All
CancellationTokenSource
instances are disposed withusing
- Linked tokens are used properly when combining cancellation sources
- Custom async methods actually respect the cancellation token they receive
Wrapping Up
Good cancellation handling makes your ASP.NET Core apps more responsive and efficient. When a user gives up on a request, your code should stop working on it too, all the way from the controller down to the database query.
This matters most for apps with long-running operations or high traffic, where every bit of server efficiency counts.
Remember these basics:
- Pass HttpContext.RequestAborted down through all your layers
- Use linked tokens when you need multiple ways to cancel
- Check for cancellation during heavy CPU work
- Make sure your database calls get the token
- Handle OperationCanceledException in the right places
Following these simple patterns will make your apps faster, more reliable, and better at handling the real world where users change their minds.
Frequently Asked Questions
Why should I care about request cancellation in ASP.NET Core?
What is HttpContext.RequestAborted and how do I use it?
How do I combine request cancellation with timeouts?
Do EF Core operations support cancellation?
What happens if I ignore a cancellation token?
How does cancellation work with dependency injection in ASP.NET Core?
Related Posts
- Dependency Inversion Principle in C#: Flexible Code with ASP.NET Core DI
- Understanding Route Constraints in ASP.NET Core
- C# Data Annotations: Complete Guide with Examples, Validation, and Best Practices
- Add & Modify HTTP Headers in ASP.NET Core Middleware
- ASP.NET Core Middleware Order: Fix Pipeline Issues and Debug Execution Flow