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:

  1. Your server wastes time on work nobody needs
  2. Database connections stay open unnecessarily
  3. Your app handles fewer concurrent users
  4. 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:

  1. User action (closing browser tab, clicking stop)
  2. HTTP pipeline (through HttpContext.RequestAborted)
  3. Controller action
  4. Service layer
  5. Repository/data access layer
  6. 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:

ApproachBenefitsDrawbacks
No cancellation handlingSimplest codeWastes resources, can hang your app
HttpContext.RequestAborted onlySimple, handles client disconnectsDoesn’t support timeouts or app-specific cancellation
Linked cancellation tokensFlexible, combines multiple cancellation sourcesSlightly more complex, need to manage CancellationTokenSource instances
Custom timeout per operationFine-grained controlNeed 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:

  1. Forgetting to pass the token to methods that support it
  2. Catching OperationCanceledException but not checking which token triggered it
  3. Making new tokens when you should pass the existing one
  4. Not disposing CancellationTokenSource objects
  5. 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:

  1. Pass HttpContext.RequestAborted down through all your layers
  2. Use linked tokens when you need multiple ways to cancel
  3. Check for cancellation during heavy CPU work
  4. Make sure your database calls get the token
  5. 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.