Nothing is stopped for you
The word suggests something is aborted. Nothing is. A CancellationToken is a
flag with a change notification attached, and cancelling it sets the flag. Every
piece of work continues exactly as before until some code chooses to look.
There is no safe way to make it otherwise. Killing a thread mid-operation leaves
locks held and state half-written, which is why Thread.Abort was removed from
modern .NET rather than improved. Cooperative cancellation is the deliberate
alternative, and its cost is that "cooperative" means somebody has to cooperate.
So the useful question when work refuses to stop is never "why did cancellation
fail" — it is "where did the token stop travelling".
Where it comes from and where it breaks
In ASP.NET Core the framework hands you one. HttpContext.RequestAborted is
cancelled when the client disconnects, and binding a CancellationToken
parameter on an action gives you that same token without asking.
[HttpGet("/reports/{id}")]
public async Task<IActionResult> Get(string id, CancellationToken ct)
{
var report = await _reports.BuildAsync(id, ct);
return Ok(report);
}
The break is almost always one layer down, and it is silent because the code
compiles and looks correct:
public async Task<Report> BuildAsync(string id, CancellationToken ct)
{
var rows = await _db.QueryAsync(sql, id);
var enriched = await _http.GetAsync(url);
return Compose(rows, enriched);
}
Nothing here is wrong to the compiler. An analyser will flag some of it, and the
habit worth building is that a method taking a token should pass it to every
call that accepts one — a token parameter that is never used is a defect even
though it is not an error.
Two things a token does not cover
CPU-bound loops. A token can only be observed at a point where somebody
looks, and a tight computation has no await at which to look. You have to
check:
foreach (var row in rows)
{
ct.ThrowIfCancellationRequested();
Accumulate(row);
}
ThrowIfCancellationRequested rather than testing IsCancellationRequested
yourself, because the exception is how the rest of the stack learns about it.
Work that must not stop. This is the judgement half, and it is what
separates a considered answer. Cancellation is right for reads, for anything the
caller is waiting on, and for speculative work. It is wrong once you are
committing something: abandoning a database write halfway leaves the system in a
state nobody designed, and abandoning a compensating action leaves the thing it
was compensating for. Those sections should run to completion and be made
idempotent, so a retry from the caller is safe.
The pattern that expresses this is to stop taking the token once the write
begins, and to say so:
await _ledger.PostAsync(entry, CancellationToken.None);
CancellationToken.None written explicitly is better than omitting the
parameter, because it records the decision.
Timeouts are the same mechanism
A timeout is a token cancelled by a clock, which means combining a caller's
token with a deadline is composition rather than a separate feature.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var result = await _http.GetAsync(url, linked.Token);
Two details matter. The sources are disposable and leak timers if they are not
disposed, which is what the using declarations are for. And a linked source
fires when either input does, so afterwards you often need to know which —
ct.IsCancellationRequested distinguishes a client that left from a dependency
that was too slow, and those deserve different log lines and different
responses.
What to do when it fires
An OperationCanceledException reaching your handler is the system working, not
a failure. Logging it at error level produces a dashboard where every user who
navigated away looks like an outage, and teams then tune alerts around the noise
until a real error hides in it.
The conventional handling is to catch it, distinguish caller cancellation from a
timeout, and respond accordingly: nothing useful can be sent to a client that
has disconnected, while a timeout against a dependency is a 504 and is worth a
warning. Frameworks increasingly do the first part for you, and knowing that it
should not be an error is the point.
Cancellation is a request that something stop, delivered to code that has to
agree. When work carries on, look for the method that took the token and did
not pass it on.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).