Skip to content
QSWEQB
mediumConceptCodingScenarioMidSenior

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.

4 min readUpdated 2026-07-26

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

Further reading

cancellationasynctasksaspnet-corecsharp