CancellationTokenSource and token, ThrowIfCancellationRequested, Register, OperationCanceledException, RequestAborted in ASP.NET Core, disposal and where not to cancel.
Cancellation in .NET is cooperative. A CancellationTokenSource owns the decision to cancel; it hands out CancellationTokens that code checks with IsCancellationRequested or ThrowIfCancellationRequested(), or passes to APIs that support it. When cancelled, the convention is to throw OperationCanceledException, which makes the task Canceled. Good APIs accept a CancellationToken as the last parameter (defaulting to default), pass it to every async call they make, and never swallow cancellation. In ASP.NET Core, a CancellationToken parameter is bound to HttpContext.RequestAborted, so client disconnects stop the work.
"Cancellation in .NET is cooperative. Nothing is ever forcibly stopped: a
CancellationTokenSourceowns the decision to cancel, and the code doing the work observes aCancellationTokenand stops at a safe point. Code observes it by passing the token to async APIs that support it (HttpClient, EF Core, streams,Task.Delay, channels), by callingThrowIfCancellationRequested()in CPU loops, or by usingRegisterto unblock APIs that don't take tokens. The convention when cancelled is to throwOperationCanceledException, which makes the taskCanceledrather thanFaulted. Never return partial results silently. For API design: every public async method takes aCancellationToken cancellationToken = defaultas its last parameter and forwards it to every call. Cancellation is caught only at the boundary that requested it, and isn't logged as an error. In ASP.NET Core, aCancellationTokenparameter is bound toHttpContext.RequestAborted, so client disconnects stop database and HTTP work. The subtle senior point is the point of no return: after an irreversible step like charging a card, you stop honouring cancellation so you don't leave inconsistent state."
CancellationTokenSource (owner) |
CancellationToken (observer) |
|---|---|
Cancel(), CancelAsync() (.NET 8+), CancelAfter(timeout) |
IsCancellationRequested, ThrowIfCancellationRequested() |
new CancellationTokenSource(timeout) |
Register(callback) / UnsafeRegister |
CreateLinkedTokenSource(...) |
CanBeCanceled, WaitHandle |
TryReset() (.NET 6+) |
CancellationToken.None (never cancels) |
Dispose() |
— (struct, nothing to dispose) |
Separating them means callees can observe cancellation but can't trigger it.
| Kind of work | How to honour the token |
|---|---|
| Async I/O (HTTP, DB, files, channels, delays) | Pass the token: await db.Orders.ToListAsync(ct) |
| CPU-bound loops | ct.ThrowIfCancellationRequested() every N iterations |
| Blocking API without token support | using var reg = ct.Register(() => socket.Close()); to unblock it |
| Awaiting something non-cancellable | await op.WaitAsync(ct) (.NET 6+) to stop waiting (the op continues) |
IAsyncEnumerable<T> |
[EnumeratorCancellation] parameter + WithCancellation(ct) |
| Rule | Why |
|---|---|
Task<T> DoAsync(..., CancellationToken cancellationToken = default) |
Convention; callers can omit it |
| Forward it to every inner async call | Otherwise the expensive work keeps running (CA2016 flags misses) |
Throw OperationCanceledException |
Callers can distinguish cancelled from failed |
| Don't catch-and-continue deep in the stack | Callers would think the work completed |
| Catch only where cancellation was requested | catch (OperationCanceledException) when (ct.IsCancellationRequested) |
| Don't take a token you can't honour | Misleading contract |
| Stop honouring after a point of no return | Use CancellationToken.None for commits after irreversible actions |
| Source | Token |
|---|---|
| HTTP request (minimal APIs, controllers, gRPC) | HttpContext.RequestAborted (bound automatically to a CancellationToken parameter) |
BackgroundService |
stoppingToken in ExecuteAsync |
| Host shutdown | IHostApplicationLifetime.ApplicationStopping |
| User clicks "Cancel" | Your own CancellationTokenSource |
| Timeouts | CancelAfter, or a linked source (next question) |
Separate cancelled from failed in logs and metrics. A spike in cancellations usually means slow responses (users giving up) or aggressive client timeouts, not bugs.
Our [product search endpoint] ran heavy SQL queries of [up to 20 seconds], and users often typed a new query before results arrived. During peaks database CPU hit [90%], and logs showed many queries completing after their HTTP clients had disconnected. The endpoint neither accepted nor forwarded a CancellationToken. I added a CancellationToken parameter (bound to RequestAborted) and passed it through the service layer to EF Core's ToListAsync(ct), which cancels the command on SQL Server. Abandoned queries stopped running, and peak database CPU dropped by [about a quarter]. We enabled CA2016 as an error so tokens are always forwarded, and added a "cancelled requests" metric separate from errors.
Checks practical, correct cancellation design.
Green flags
OperationCanceledException.RequestAborted.Red flags
Thread.Abort could stop code anywhere: inside a lock, halfway through updating shared state, during a static constructor. Cooperative cancellation lets code stop only at points it chooses, and finally blocks run normally as the OperationCanceledException propagates.
Cancel()cts.Cancel():
set state = cancelled (once)
run registered callbacks synchronously, on THIS thread
(exceptions from callbacks are aggregated; Cancel(throwOnFirstException) controls behaviour)
cts.CancelAsync() (.NET 8+):
set state, then run callbacks asynchronously so the caller isn't blocked by them
Because callbacks run inline, a slow or blocking callback delays whoever called Cancel(), which might be a request thread or a timer. Keep callbacks tiny and non-throwing, and don't depend on the order in which callbacks run.
public async Task<Reply> SendAsync(Request r, CancellationToken ct)
{
var tcs = new TaskCompletionSource<Reply>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[r.Id] = tcs;
using CancellationTokenRegistration reg = ct.Register(() =>
{
if (_pending.TryRemove(r.Id, out var t)) t.TrySetCanceled(ct);
});
await _socket.SendAsync(r.Bytes, ct);
return await tcs.Task;
} // reg disposed: no callback left on a long-lived token
If ct is long-lived (e.g. the host's stopping token) and registrations aren't disposed, callbacks and their captured state accumulate: a memory leak.
for (int i = 0; i < rows.Length; i++)
{
if ((i & 0x3FF) == 0) ct.ThrowIfCancellationRequested(); // every 1,024 rows
Process(rows[i]);
}
Checking is cheap (a volatile read), but not free in the tightest loops; aim for responsiveness in the tens of milliseconds.
public async Task<OrderResult> CheckoutAsync(Cart cart, CancellationToken ct)
{
var quote = await _pricing.QuoteAsync(cart, ct); // cancellable
var payment = await _payments.ChargeAsync(quote, ct); // last cancellable step
// Point of no return: money moved. Finish consistently even if the client disconnects.
var order = await _orders.CreateAsync(cart, payment, CancellationToken.None);
await _outbox.EnqueueAsync(new OrderPlaced(order.Id), CancellationToken.None);
return new OrderResult(order.Id);
}
Pair this with idempotency keys so retries after a disconnect don't double-charge.
CancelAfter/timeouts (they own a timer) and linked sources (they register on parent tokens).Cancel() on it.TryReset() (.NET 6+) allows reuse of a source that hasn't been cancelled, useful in pooled scenarios.| Version | Change |
|---|---|
| .NET Framework 4.0 | CancellationToken, CancellationTokenSource |
| .NET Framework 4.5 | CancelAfter, timeout constructor |
| .NET Core 3.0 | UnsafeRegister; IAsyncEnumerable + [EnumeratorCancellation] |
| .NET 6 | TryReset, Task.WaitAsync(ct) |
| .NET 8 | CancelAsync |
| .NET 10 | Same model |
Cancelled as requested message.Task.Delay and see cancellation become slower (only checked per batch).Register callback that sleeps 2 s and measure how long Cancel() takes; compare with CancelAsync().CancellationToken, await Task.Delay(10_000, ct), call it with curl and press Ctrl+C: observe the cancellation.using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromMilliseconds(200));
try
{
long total = await SumAsync(cts.Token);
Console.WriteLine($"Total {total}");
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
Console.WriteLine("Cancelled as requested"); // expected, not an error
}
static async Task<long> SumAsync(CancellationToken cancellationToken = default)
{
using var registration = cancellationToken.Register(() => Console.WriteLine("Cancel callback ran"));
long sum = 0;
for (int batch = 0; batch < 1_000; batch++)
{
cancellationToken.ThrowIfCancellationRequested(); // CPU loop check
for (int i = 0; i < 100_000; i++) sum += i;
await Task.Delay(1, cancellationToken); // pass the token down
}
return sum;
}
// ASP.NET Core: the token is bound to HttpContext.RequestAborted.
// app.MapGet("/orders", async (OrdersDb db, CancellationToken ct) =>
// await db.Orders.Where(o => o.Open).ToListAsync(ct));
What happens: DoAsync(ct) calls db.SaveChangesAsync() without ct.
Why it is wrong: the expensive work keeps running after cancellation.
Do instead: forward to every inner call; enable CA2016.
What happens: catch (OperationCanceledException) { return null; } in a repository.
Why it is wrong: callers treat a cancelled operation as a successful empty result.
Do instead: let it propagate; catch only where cancellation was requested.
What happens: the order insert after a successful payment uses the request token.
Why it is wrong: a client disconnect leaves a charge without an order.
Do instead: CancellationToken.None for commit steps; idempotency keys for retries.
What happens: client disconnects show up as errors. Why it is wrong: noisy dashboards; real errors are hidden. Do instead: log at debug/information; separate metrics.
What happens: ct.Register(...) per operation on the host stopping token.
Why it is wrong: callbacks and captured state accumulate (leak).
Do instead: using var reg = ct.Register(...).
Register callbacksWhat happens: a callback does I/O or throws.
Why it is wrong: it runs inline in Cancel(), blocking the canceller; exceptions surface there.
Do instead: keep callbacks tiny; consider CancelAsync().
What happens: new CancellationTokenSource(TimeSpan.FromSeconds(5)) per request, never disposed.
Why it is wrong: timers linger until they fire.
Do instead: using var cts = ....
WaitAsync(ct) as cancelling the workWhat happens: a non-cancellable operation is wrapped with WaitAsync(ct).
Why it is wrong: only the wait stops; the operation continues and may have side effects.
Do instead: use APIs that truly accept tokens where possible.
IsCancellationRequested and returning silentlyWhat happens: if (ct.IsCancellationRequested) return; in a method returning data.
Why it is wrong: callers can't tell cancellation from success.
Do instead: ThrowIfCancellationRequested().
CancellationToken.None everywhere "to be safe"What happens: tokens are ignored to avoid exceptions. Why it is wrong: wasted work, slow shutdowns, poor capacity under load. Do instead: honour cancellation except deliberately after points of no return.
I treat cancellation as part of every async API's contract and as a capacity tool: abandoned work is wasted work. The subtle part is deciding where cancellation must stop being honoured.
| Decision | Guidance |
|---|---|
| Accept a token? | Yes on every public async method |
| Forward it? | Always, until a point of no return |
| After an irreversible step | Use CancellationToken.None |
| Report cancellation | OperationCanceledException, not partial results |
| Observability | Separate cancelled from failed in metrics |
CancellationTokenSource.CreateLinkedTokenSource(a, b, ...) creates a new source that is cancelled when any of the given tokens is cancelled, and can also be cancelled itself (for example with CancelAfter). It is how you combine "the caller cancelled" with "my own timeout" or "the host is shutting down". You must dispose the linked source, because it registers callbacks on the parent tokens; forgetting to do that on long-lived tokens leaks memory. To report the right error, check which source fired: if the caller's token is cancelled, rethrow cancellation; if only your timeout fired, throw a TimeoutException.
A timeout should cancel the work, not just stop waiting. Use a CancellationTokenSource with CancelAfter (linked to the caller's token) and pass it down. Task.WaitAsync(timeout) only stops waiting. For HTTP, HttpClient.Timeout is a single total timeout (100 seconds by default) that throws TaskCanceledException with an inner TimeoutException; in production I prefer AddStandardResilienceHandler, which sets attempt and total timeouts plus retries. In ASP.NET Core 8+, the request timeouts middleware cancels RequestAborted after a configured time. Timeouts must be layered: each inner timeout shorter than the outer one.
Fire-and-forget means starting a task and not awaiting it, such as _ = SendEmailAsync(). The risks are: exceptions are never observed, scoped services like DbContext are disposed when the request ends (giving ObjectDisposedException), work is lost on shutdown or crash, there is no limit on how much work piles up, and HttpContext may be used after the request is gone. In ASP.NET Core, hand the work to a queue (Channel<T>) processed by a BackgroundService that creates its own scope, logs failures and drains on shutdown. If the work must not be lost, use a durable queue or an outbox table.
How to tell it (STAR)
Our [search API] kept running expensive queries after users had gone. I bound the request's cancellation token and passed it to EF Core, so abandoned queries were cancelled in SQL Server. Database CPU at peak dropped by [about a quarter], and we enforced CA2016 so tokens are always forwarded.
Last updated 31 Oct 2026.