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:

  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
Flowchart diagram showing how cancellation signals travel from browser user through ASP.NET Core layers to database

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.

Sequence diagram showing how linked cancellation tokens handle both timeout and client disconnection scenarios

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.

Sequence diagram showing cancellation propagating from controller through Entity Framework Core to SQL Server

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:

Class diagram showing the relationships between Controller, Service, and Repository components with cancellation token integration

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:

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

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 with using
  • 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:

  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.

Frequently Asked Questions

Why should I care about request cancellation in ASP.NET Core?

Handling cancellation helps save server resources when clients disconnect or cancel requests. Without proper cancellation, your server continues processing abandoned requests, wasting CPU time, database connections, and memory that could serve other users. It also improves application responsiveness and resource utilization.

What is HttpContext.RequestAborted and how do I use it?

HttpContext.RequestAborted is a CancellationToken that’s triggered when a client disconnects from your ASP.NET Core application. You use it by passing it to async methods throughout your request pipeline, like HttpClient.GetAsync(url, HttpContext.RequestAborted) or await dbContext.SaveChangesAsync(HttpContext.RequestAborted).

How do I combine request cancellation with timeouts?

Use CancellationTokenSource.CreateLinkedTokenSource() to combine multiple cancellation signals. For example, link HttpContext.RequestAborted with a timeout token - var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutToken, HttpContext.RequestAborted). The linked token cancels if either the client disconnects or the timeout occurs.

Do EF Core operations support cancellation?

Yes, most async Entity Framework Core operations accept a cancellation token parameter. Methods like ToListAsync(), FirstOrDefaultAsync(), and SaveChangesAsync() all support cancellation. When the token is canceled, EF Core will attempt to cancel the underlying database command if the provider supports it.

What happens if I ignore a cancellation token?

If you don’t pass cancellation tokens through your code, operations will continue running even after clients disconnect. This leads to wasted resources and potentially slower response times for active users. Database connections might remain open longer than necessary and your server won’t be able to handle as many concurrent requests.

How does cancellation work with dependency injection in ASP.NET Core?

For scoped services, pass the HttpContext.RequestAborted token from controllers to services. For singleton services that need cancellation, ask for IHttpContextAccessor in the constructor to access the current request’s token, or have methods accept cancellation tokens as parameters passed from higher layers.

Related Posts