Handling Cancellation in ASP.NET Core: From Browser to Database
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
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.
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.
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:
// 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
When working with cancellation, here are the traps I’ve fallen into that you should avoid:
- Forgetting to pass the token to methods that support it
- Catching OperationCanceledException but not checking which token triggered it
- Making new tokens when you should pass the existing one
- Not disposing CancellationTokenSource objects
- Long CPU work that never checks for cancellation
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.