A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?
A CancellationToken is cooperative: nothing is stopped for you, and work continues until something checks the token or an awaited call observes it. Propagation breaks wherever a token is accepted and not passed on, which is why the fix is usually threading it through rather than adding a check.
What the interviewer is scoring
- Whether the candidate says cancellation is cooperative rather than pre-emptive
- That an accepted token which is not passed downstream is identified as the usual break
- Does the candidate distinguish work that should stop from work that must complete
- Whether OperationCanceledException is treated as expected rather than as an error to log
- That a CPU-bound loop is known to need an explicit check
Answer
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); // pass it on
return Ok(report);
}
The break is almost always one layer down, and it is silent because the code compiles and looks correct:
// Accepts a token and then abandons it. Every call below runs to completion
// after the client has gone, holding a connection and a thread pool slot.
public async Task<Report> BuildAsync(string id, CancellationToken ct)
{
var rows = await _db.QueryAsync(sql, id); // no ct
var enriched = await _http.GetAsync(url); // no ct
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(); // cheap; check per item, not per field
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:
// Deliberately not cancellable: partial completion here is worse than
// finishing work the caller has stopped waiting for.
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); // whichever fires first
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.
Likely follow-ups
- Where does the token in an ASP.NET Core action come from?
- Should a cancelled request be logged as an error? What status should it return?
- You are halfway through writing to the database when cancellation fires. What now?
- How do you add a timeout to an operation that already has a caller's token?
Related questions
- A customer cancels a twelve-month policy after four months. How much do you refund?hardAlso on cancellation4 min
- When would you make a type a record, a struct, or a class? What does each choose for you?mediumAlso on csharp4 min
- A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?mediumAlso on cancellation7 min
- You run ten coroutines concurrently and one of them raises. What happens to the other nine?hardAlso on cancellation5 min
- You added an authorisation policy to an ASP.NET Core endpoint and it is not being enforced. Where do you look?mediumAlso on aspnet-core3 min
- Why does calling .Result or .Wait() on an async method deadlock, and how do you fix it properly?hardAlso on aspnet-core5 min
- What is the difference between a value type and a reference type in C#, and what do nullable reference types guarantee?mediumAlso on csharp5 min
- Walk me through the phases of the Node event loop.mediumAlso on async4 min