📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does cooperative cancellation with CancellationToken work in .NET, and how do you design APIs that support it?

CancellationTokenSource and token, ThrowIfCancellationRequested, Register, OperationCanceledException, RequestAborted in ASP.NET Core, disposal and where not to cancel.

Mid-Level Coding Intermediate aspnet-core async cancellation cancellationtoken

🎯 Key points

  • Cancellation is cooperative: a source cancels, tokens are observed
  • Check with ThrowIfCancellationRequested or pass the token to async APIs
  • Convention: throw OperationCanceledException, giving a Canceled task
  • Accept a CancellationToken last with a default and forward it everywhere
  • ASP.NET Core binds CancellationToken parameters to RequestAborted
  • Do not cancel after a point of no return
  • Dispose sources and registrations you create

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"Cancellation in .NET is cooperative. Nothing is ever forcibly stopped: a CancellationTokenSource owns the decision to cancel, and the code doing the work observes a CancellationToken and 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 calling ThrowIfCancellationRequested() in CPU loops, or by using Register to unblock APIs that don't take tokens. The convention when cancelled is to throw OperationCanceledException, which makes the task Canceled rather than Faulted. Never return partial results silently. For API design: every public async method takes a CancellationToken cancellationToken = default as 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, a CancellationToken parameter is bound to HttpContext.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."


1. The two types

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.

2. How code reacts

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)

3. API design rules

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

4. Where tokens come from

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)

5. Observability

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.

Production example

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.

🔍 What the interviewer is checking

Checks practical, correct cancellation design.

Green flags

  • Explains source versus token and cooperative semantics.
  • Passes tokens down and throws OperationCanceledException.
  • Knows ASP.NET Core binds RequestAborted.
  • Mentions points of no return and logging.

Red flags

  • Ignores tokens or swallows cancellation.
  • Thinks cancellation stops code forcibly.

📚 Learn in depth

1. Why cooperative

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.

2. What happens inside 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.

3. Registrations and leaks

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.

4. CPU loops: how often to check

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.

5. Points of no return

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.

6. Disposal of sources

  • Dispose sources you create, especially with CancelAfter/timeouts (they own a timer) and linked sources (they register on parent tokens).
  • Don't dispose a source while other code may still call Cancel() on it.
  • TryReset() (.NET 6+) allows reuse of a source that hasn't been cancelled, useful in pooled scenarios.

7. Version notes

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

8. Try it yourself

  1. Run the code example and observe the callback and the Cancelled as requested message.
  2. Remove the token from Task.Delay and see cancellation become slower (only checked per batch).
  3. Add a Register callback that sleeps 2 s and measure how long Cancel() takes; compare with CancelAsync().
  4. In a minimal API, accept a CancellationToken, await Task.Delay(10_000, ct), call it with curl and press Ctrl+C: observe the cancellation.
  5. Register 100,000 callbacks on a long-lived token without disposing them and watch memory.
  6. Enable CA2016 and fix the warnings in a sample service.

💻 Code

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));

⚠ Common mistakes

1. Accepting a token but not forwarding it

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.

2. Swallowing cancellation deep in the stack

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.

3. Cancelling after a point of no return

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.

4. Logging cancellations as errors

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.

5. Not disposing registrations on long-lived tokens

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(...).

6. Slow or throwing Register callbacks

What 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().

7. Not disposing timeout sources

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 = ....

8. Treating WaitAsync(ct) as cancelling the work

What 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.

9. Using IsCancellationRequested and returning silently

What happens: if (ct.IsCancellationRequested) return; in a method returning data. Why it is wrong: callers can't tell cancellation from success. Do instead: ThrowIfCancellationRequested().

10. Passing 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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate wants to add a timeout to the caller's token Why: Checks linked tokens
    How to answer

    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.

  2. When: Candidate mentions CancelAfter Why: Probes timeout design
    How to answer

    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.

  3. When: Candidate mentions the stopping token in hosted services Why: Checks safe background work
    How to answer

    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.

🛠 Real-world problems: how you handle them

Abandoned search queries overloading the database
Situation
A [product search endpoint] ran heavy SQL queries of [up to 20 seconds]; users often typed again before results arrived.
Signals
Database CPU at [90%] during peaks; many queries completed after the client had disconnected.
Investigation
Logs showed query completions with no matching HTTP responses; the endpoint did not accept or forward a `CancellationToken`.
Resolution
Added a `CancellationToken` parameter (bound to `RequestAborted`) and passed it to EF Core's `ToListAsync`.
Prevention
Enabled CA2016 as a warning-as-error and added a metric for cancelled requests.

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.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me