Mert Özen Further With Every Line
Backend .NET Core Web API — #17

Async/Await and CancellationToken in ASP.NET Core: Carrying Cancellation All the Way to the Database

Mert Özen Nov 4, 2026 8 min 1 views
Async/Await and CancellationToken in ASP.NET Core: Carrying Cancellation All the Way to the Database

We watch threads return to the pool during await, measure how blocking code starves the thread pool, carry a CancellationToken from the action to EF Core, and cover the .Result and async void traps.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

You have a sales report endpoint that takes ten seconds. A user requests the report, waits two seconds, gets bored and closes the tab. Does the server keep building the report? What is the answer if you change nothing in the code, and what changes if you add a single parameter to the action? Hold an answer in your head; in a moment we will run exactly this experiment and read the logs.

The Thread Pool: A Limited Number of Waiters

Kestrel takes a thread from the thread pool to process each incoming request. The thread pool is a pool of threads that live for the lifetime of the app and get reused. The pool can grow, but under a sudden burst it adds new threads gradually; it does not open hundreds of threads at once.

Picture a restaurant with a limited number of waiters. If a waiter passes the order to the kitchen and then stands at the kitchen door until the food is cooked, they can't look after any other table in the meantime. A smart waiter drops off the order, moves on to other tables, and when the food is ready whoever is free carries the plate. Synchronous code is the waiter standing at the door; async code is the waiter moving on to other tables.

APIs spend most of their time waiting: for the database, for another service's HTTP response, for the disk. Leaving a thread idle during that wait is waste. await solves exactly that: it says "let go of me until this finishes, then resume me where I left off". Async code does not make a single request faster; it lets the server carry more requests at the same time with the same number of threads.

Where Does the Thread Go During await?

Let's see it with our own eyes. We write a small diagnostics endpoint:

[HttpGet("threads")]
public async Task<IActionResult> Threads(int request)
{
    _logger.LogInformation("Request {Request} before await: thread {ThreadId}",
        request, Environment.CurrentManagedThreadId);
    await Task.Delay(500);
    _logger.LogInformation("Request {Request} after await: thread {ThreadId}",
        request, Environment.CurrentManagedThreadId);
    return Ok();
}

When we sent four requests at once, the log on .NET 6 looked like this:

Request 2 before await: thread 13
Request 1 before await: thread 16
Request 3 before await: thread 9
Request 4 before await: thread 10
Request 3 after await: thread 9
Request 4 after await: thread 14
Request 2 after await: thread 13
Request 1 after await: thread 10

Request 1 started on thread 16 and continued on thread 10. Thread 10, meanwhile, had just been serving request 4. At the await the thread went back to the pool, and when the wait was over whichever thread was free picked up the work. Your numbers will differ; what matters is that the same request can run on different threads on either side of an await.

The Cost of Blocking

Let's write the same one-second wait in two different ways:

[HttpGet("blocking")]
public IActionResult Blocking()
{
    // blocks the thread for one second
    Task.Delay(1000).Wait();
    return Ok();
}

[HttpGet("non-blocking")]
public async Task<IActionResult> NonBlocking()
{
    // releases the thread and resumes one second later
    await Task.Delay(1000);
    return Ok();
}

We sent 200 concurrent requests to each endpoint. On my 10-core machine with .NET 6, the non-blocking requests finished in about 1.2 seconds; the blocking ones took between 5.7 and 5.9 seconds across a few runs. Each request only waits one second, but in the blocking version the threads are standing at the door, the pool can't keep up and requests queue. This is called thread pool starvation. In a real system it shows up as response times hitting the ceiling while the CPU sits idle, and it is hard to diagnose.

CancellationToken: Finding Out When the Client Leaves

Now back to the opening question. Two versions of the report endpoint:

[HttpGet("sales")]
public async Task<IActionResult> Sales(CancellationToken cancellationToken)
{
    for (int step = 1; step <= 10; step++)
    {
        // each step stands in for a heavy one-second query
        await Task.Delay(1000, cancellationToken);
        _logger.LogInformation("Sales report step {Step}/10 done", step);
    }

    return Ok(new { status = "ready" });
}

[HttpGet("sales-no-token")]
public async Task<IActionResult> SalesNoToken()
{
    for (int step = 1; step <= 10; step++)
    {
        await Task.Delay(1000);
        _logger.LogInformation("Sales report (no token) step {Step}/10 done", step);
    }

    return Ok(new { status = "ready" });
}

We imitate the user who closes the tab with curl --max-time 2; curl drops the connection after two seconds:

curl --max-time 2 http://localhost:5000/api/reports/sales-no-token
curl --max-time 2 http://localhost:5000/api/reports/sales

The log of the version without a token (middle lines trimmed):

info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 1/10 done
info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 2/10 done
...
info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 10/10 done

The client left after two seconds, and the server spent eight more seconds building a report nobody would read. In real life that means heavy queries kept open in the database for eight seconds. With the token version, on .NET 6, we saw this:

info: ProductCatalog.Controllers.ReportsController[0]
      Sales report step 1/10 done
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.Threading.Tasks.TaskCanceledException: A task was canceled.

The moment the connection dropped, the token was cancelled, the pending Task.Delay threw a TaskCanceledException and the work stopped at step two. MVC automatically binds a CancellationToken parameter on an action to HttpContext.RequestAborted; no attribute and no registration needed. You can take it as a parameter in minimal API handlers the same way.

The fail line in the log is misleading, though: this isn't an error, it's the client giving up. .NET 6 doesn't tell the two apart; starting with .NET 8 the framework recognizes this case itself and stays quiet, which we will see in the version posts later on. On .NET 6 we will clean up this noise with our own middleware in the global error handling post.

Carrying the Token Through Every Layer

For the token to actually help, it has to reach the end of the chain, the database call. First, the service contract:

public interface IProductService
{
    Task<List<ProductDto>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task<ProductDto?> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken = default);
    Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default);
}

Thanks to = default, callers without a token (a test, a console tool) can skip the parameter. On the service side, every async method in EF Core accepts a token:

public async Task<List<ProductDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
    return await _db.Products
        .AsNoTracking()
        .OrderBy(p => p.Id)
        .Select(ToDto)
        .ToListAsync(cancellationToken);
}

public async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
{
    Product? product = await _db.Products.FindAsync(new object[] { id }, cancellationToken);
    if (product is null)
    {
        return false;
    }

    _db.Products.Remove(product);
    await _db.SaveChangesAsync(cancellationToken);
    return true;
}

Watch the signature of FindAsync: the key values are params object[], so when you want to pass a token you have to put the key in an array explicitly. If you write FindAsync(id, cancellationToken) the code compiles, because the compiler treats the token as a second key value. On EF Core 6 the error shows up at runtime:

ArgumentException: Entity type 'Product' is defined with a single key property, but 2 values were passed to the 'Find' method.

EF Core 7 and later recognize the trailing token and interpret it correctly; but if you are on .NET 6 with EF Core 6, the array form is required. The controller side just takes the token and passes it on:

[HttpGet]
public async Task<ActionResult<List<ProductDto>>> GetAll(CancellationToken cancellationToken)
{
    return await _productService.GetAllAsync(cancellationToken);
}

The token in write operations

For read queries, passing the token is almost always right. For writes there are two approaches. The first is to pass the token to SaveChangesAsync too: if the client has left, the write never starts or the transaction is rolled back and the database stays consistent, but the client walked away without knowing whether anything was saved. The second is not to pass a token at the write step: once the operation starts it always completes, and the price is finishing work the client will never see. For operations that are confusing when left half-done, such as orders and payments, the second path is safer; for most simple CRUD the first is enough. Whichever you pick, SaveChangesAsync runs in a single transaction, so you never get a half-written record.

.Result, .Wait() and Other Synchronous Traps

A pattern you often see in older projects:

[HttpGet]
public ActionResult<List<ProductDto>> GetAll()
{
    // DON'T: waits on async code synchronously
    return _productService.GetAllAsync().Result;
}

This code works. In classic ASP.NET or desktop apps this pattern would deadlock, because there was a SynchronizationContext the continuation after await had to wait for. ASP.NET Core has no such context, so you won't see a hang. That doesn't make the code right: .Result blocks the thread for the whole wait and reproduces exactly the starvation picture we just measured. .Wait() and .GetAwaiter().GetResult() end up in the same place.

The rule is simple: an async chain should be async from top to bottom. Once you start using await somewhere, the method calling it becomes async Task too, and so does its caller. It goes all the way up to the action; ASP.NET Core already knows how to await actions that return a Task.

Common Pitfalls

Writing async void. Nobody can await an async void method; an exception thrown inside it can't be caught and can crash the process. Outside event handlers, always use async Task.

"Making it async" with Task.Run in a controller. await Task.Run(() => _service.GetAll()) only moves the work to another thread in the pool; you free one thread while occupying another. No gain, just extra cost. Real async comes from APIs that do I/O asynchronously (ToListAsync, HttpClient.GetAsync).

Dropping the token halfway. If the action takes the token and passes it to the service, but the service calls ToListAsync() without it, the most expensive link in the chain never hears about the cancellation.

Writing FindAsync(id, cancellationToken). As discussed above, it compiles, but on EF Core 6 it blows up at runtime because the key count doesn't match; the correct form is FindAsync(new object[] { id }, cancellationToken).

Sprinkling ConfigureAwait(false) everywhere. ASP.NET Core application code has no SynchronizationContext, so it has no effect. It makes sense if you are writing a library that runs in several environments.

Logging cancellation as an error. An OperationCanceledException tells you the client gave up; leaving it as an alarm-raising fail log entry is needless noise.

A Small Experiment

★☆☆ Send ten requests at once to the Threads endpoint and count which requests switched threads after the await.

★★☆ Send 500 concurrent requests each to the blocking and non-blocking endpoints and compare the times. Then add ThreadPool.SetMinThreads(500, 500); at the top of Program.cs and measure again; think about why the gap closes and why that is not a real fix.

★★★ Add a three-second "notify the stock service" step to CreateAsync after the product is saved (imitate it with Task.Delay). If the client leaves in the middle of those three seconds, does the product stay saved? Try how the behavior changes depending on where you pass the token.

Cancelled requests still show up as fail in the logs, and we are still carrying "not found" and "name already exists" out of the service as null. In the next post we will bring all of this into one place: UseExceptionHandler, our own exception middleware and a global error handler that turns domain exceptions into ProblemDetails responses with the right status codes.