Async handlers and cancellation
Why blocking on a Task starves the server, how RequestAborted reaches your code, and how to tell a client disconnect from a timeout.
After this lesson you can
- Explain thread pool starvation and why .Result causes it
- Flow the request's CancellationToken into the calls a handler makes
- Combine tokens with a linked source and classify the cancellation
Kestrel runs request code on thread pool threads. An await on I/O
returns the thread to the pool until the I/O completes, so a few threads
can serve thousands of concurrent requests. Blocking on a task with
.Result, .Wait() or GetAwaiter().GetResult() keeps the thread
occupied while doing nothing. Under load the pool runs out of free
threads and grows slowly, and latency climbs for every request: thread
pool starvation. ASP.NET Core has no SynchronizationContext, so the
classic UI-app deadlock does not happen; starvation is the failure mode.
The request's token
HttpContext.RequestAborted is a CancellationToken that is cancelled
when the client disconnects. A handler parameter of type
CancellationToken is bound to it, in minimal APIs and controllers
alike:
app.MapGet("/reports/{id}", async (int id, ShopDb db, CancellationToken ct) =>
await db.Reports.Where(r => r.Id == id).ToListAsync(ct));
Cancellation is cooperative. The token is only a flag; nothing stops
your code unless it checks the flag (ThrowIfCancellationRequested) or
passes the token to an API that does, such as EF Core's ...Async(ct)
methods, HttpClient and Task.Delay. Cancellation surfaces as an
OperationCanceledException (or its subclass
TaskCanceledException).
public static class Solution{ // The sandbox calls a synchronous entry point; a real handler would simply be async. public static List<string> Run() => RunAsync().GetAwaiter().GetResult(); static async Task<List<string>> RunAsync() { var log = new List<string>(); using var cts = new CancellationTokenSource(); try { for (int item = 1; item <= 5; item++) { cts.Token.ThrowIfCancellationRequested(); log.Add("processed " + item); if (item == 3) { cts.Cancel(); // the client disconnects here log.Add("cancelled, still running item " + item); } } } catch (OperationCanceledException e) { log.Add("stopped by " + e.GetType().Name); } try { await Task.Delay(Timeout.Infinite, cts.Token); // an API that takes the token } catch (OperationCanceledException e) { log.Add("Task.Delay threw " + e.GetType().Name); } return log; }}Timeouts, and telling them apart
To give one operation a deadline that also respects the client, link two sources:
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(5));
await pricing.QuoteAsync(order, cts.Token);
The linked token is cancelled when either parent is. An exception filter then tells you which one fired:
public static class Solution{ static string Attempt(bool clientLeaves, bool timeoutFires) { using var client = new CancellationTokenSource(); // stands in for RequestAborted using var timeout = new CancellationTokenSource(); // stands in for CancelAfter using var linked = CancellationTokenSource.CreateLinkedTokenSource(client.Token, timeout.Token); if (timeoutFires) timeout.Cancel(); if (clientLeaves) client.Cancel(); try { linked.Token.ThrowIfCancellationRequested(); return "completed"; } catch (OperationCanceledException) when (client.IsCancellationRequested) { return "client went away: stop quietly"; } catch (OperationCanceledException) { return "timed out: return 504"; } } public static List<string> Run() => new() { Attempt(clientLeaves: false, timeoutFires: false), Attempt(clientLeaves: false, timeoutFires: true), Attempt(clientLeaves: true, timeoutFires: false), };}The same token pattern runs a background service: ExecuteAsync
receives a stoppingToken that is cancelled when the host shuts down.
Cancellation is just as cooperative there: a worker loop that never
checks the token, or never passes it to the Task.Delay between
rounds, keeps shutdown waiting.
Common mistakes
- Calling
.Resulton an async call inside a handler. It works in development and starves the pool under production load. - Accepting a
CancellationTokenand not passing it on. The client has gone, and the database query still runs to completion. - Passing the request token to work that must finish once started, such
as recording a payment that already succeeded. Use
CancellationToken.Nonethere. - Starting
Task.Run(...)from a handler with the request's scoped services. The request ends, the scope disposes theDbContext, and the background work fails withObjectDisposedException. Hand the work to a queue and a hosted service instead.
What interviewers ask
- Why is
.Resultdangerous in ASP.NET Core if there is no deadlock? It holds a thread pool thread while waiting; under load that is thread pool starvation. - The user closes the browser tab mid-request. What happens to your
query? Only what you wired up:
RequestAbortedis cancelled, and the query stops only if the token was passed down. - A linked token source with , and an exception filter to tell the timeout from a client disconnect.
Exercise · Pro
Process items until cancelled
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade