Cancellation looks trivial from the outside — pass a CancellationToken, check it, done — but senior interviews use it to probe something harder: whether a candidate can design a system that stops cleanly under real operational pressure, not just a method that throws OperationCanceledException on command. The hard questions are about composition, such as what happens when several timeout sources overlap, about the host lifecycle, such as what actually happens between SIGTERM and process exit, and about the boundary between "stop accepting new work" and "abandon work in progress," which most engineers get wrong at least once in production. At the 10-to-20-year level, interviewers expect fluency in linked tokens, host shutdown timeouts, and how Kubernetes' termination sequence maps onto .NET's own shutdown hooks, because a service that shuts down badly causes exactly the kind of intermittent, hard-to-reproduce incident that ends up on a postmortem.
Q1 Explain the cooperative cancellation model in .NET. Why can't a CancellationToken forcibly stop a thread?#
Short answer: .NET's cancellation model is cooperative by design: a CancellationTokenSource signals that cancellation was requested, and every listener is responsible for noticing and responding on its own; nothing reaches into a running thread and stops it, because forcibly aborting a thread mid-operation leaves shared state — locks held, partially written buffers, half-updated collections — in an unknown condition.
The pattern is always the same: create a CancellationTokenSource, hand its Token to one or more operations, and later call Cancel() on the source. IsCancellationRequested flips to true on every copy of that token simultaneously — copies are cheap, CancellationToken is a small struct — but nothing happens automatically beyond that flag being set. Listeners notice the request in one of three ways: polling IsCancellationRequested in a loop for long-running computations; registering a callback through Register, which runs synchronously the moment Cancel() is called, useful for unblocking an operation that cannot poll; or waiting on the token's WaitHandle, for code blocked on a classic synchronization primitive that can wait on multiple handles at once. Task-based code mostly uses a fourth, higher-level form: passing the token into an API that already knows how to observe it, such as Task.Delay or SemaphoreSlim.WaitAsync.
Once cancellation is observed, the correct response is usually ThrowIfCancellationRequested(), which throws OperationCanceledException carrying the token. That lets calling code, including Task's own machinery, distinguish "this failed" from "this was asked to stop" — a Task that faults with that specific exception and token transitions to the Canceled state rather than Faulted.
What interviewers look for: A clear explanation of why forced thread termination is unsafe, and fluency in all three listening mechanisms, not only polling.
Common mistakes:
- Believing
Cancel()stops execution immediately rather than merely requesting it. - Catching
OperationCanceledExceptionand treating it identically to any other failure instead of letting it signal a graceful stop.
Q2 How do linked cancellation tokens work, and when do you need them?#
Short answer: CancellationTokenSource.CreateLinkedTokenSource combines two or more tokens into one new source whose token is cancelled the instant any of the originals is cancelled — exactly what you need when an operation must respect both an external caller's token and an internal condition, such as a timeout, at the same time.
public async Task<Report> BuildReportAsync(CancellationToken callerToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(callerToken, timeoutCts.Token);
return await AssembleAsync(linkedCts.Token);
}Without linking, you would have to check two tokens manually at every point you currently check one, which is error-prone and easy to get half-right. Linking collapses "cancelled by the caller" and "cancelled by our own internal timeout" into a single token that every downstream call already knows how to accept. Both the linked source and any timeout-only source must be disposed, since they hold timer and registration resources — forgetting to dispose a CancellationTokenSource created per call, in a hot path, is a real, measurable resource leak over time. A subtlety worth stating in an interview: once cancellation happens, you generally cannot tell purely from linkedCts.Token.IsCancellationRequested which of the original sources triggered it. If the caller needs to distinguish "the caller gave up" from "we timed out internally," check each original token's own state separately after catching the exception.
What interviewers look for: Knowing the disposal requirement and the "which token actually fired" subtlety — both are easy to miss and both show up in real production code.
Follow-up questions:
- How would you unit test that the internal timeout, not the caller's token, caused a given cancellation?
- What happens if you link three or more tokens together?
Q3 What's the difference between CancellationTokenSource.CancelAfter and wrapping a call in Task.WaitAsync(TimeSpan)?#
Short answer: CancelAfter schedules the source itself to transition to cancelled after a delay, so every consumer of that token sees the cancellation and can react cooperatively; Task.WaitAsync(TimeSpan) wraps a specific already-running task and stops waiting for it after the timeout, without cancelling the task itself unless that task also happens to observe the same token.
using var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(5));
await DoWorkAsync(cts.Token); // DoWorkAsync must observe cts.Token to actually stop.
// vs.
var work = DoWorkAsync(CancellationToken.None);
await work.WaitAsync(TimeSpan.FromSeconds(5)); // Throws TimeoutException; 'work' keeps running.CancelAfter is cooperative in the same way Cancel() is — it is a delayed Cancel() call, nothing more. It only stops work that actually reads the token; code that ignores the token keeps running to completion regardless. WaitAsync is a stopwatch on the caller's wait, not on the callee. If the awaited task never observes a cancellation token, calling WaitAsync on it does not cancel it — the original task keeps running in the background, consuming whatever resources it was consuming, while the caller moves on having thrown a TimeoutException. That distinction is one of the most commonly missed points in interviews: engineers assume WaitAsync cancels the underlying work the way CancelAfter does. The two are frequently combined correctly: a linked timeout token to make the operation itself actually stop, and WaitAsync as a caller-side safety net in case the operation does not honor its token promptly.
What interviewers look for: The precise distinction that WaitAsync alone never cancels the wrapped operation — this reliably separates candidates who have only read about it from those who have debugged a leak caused by assuming it does.
Common mistakes: Using WaitAsync as a substitute for threading a CancellationToken through an operation, then being surprised the operation is still running and still holding resources afterward.
Q4 How should a method that accepts a CancellationToken behave, and what commonly breaks cooperative cancellation in practice?#
Short answer: Accept the token as the last parameter, pass it to every awaited call that accepts one, check it explicitly around expensive work between awaits, and let OperationCanceledException propagate instead of catching and swallowing it; cooperative cancellation breaks whenever any link in that chain is skipped.
The most common break is a method that accepts a CancellationToken parameter and never actually passes it anywhere — a database call, an HTTP request, a delay — because the token "looked done" once it was added to the signature. The compiler does not enforce that a token is used, so this compiles cleanly and only fails at runtime, under load, when cancellation silently stops working for that one call. A close second is catching OperationCanceledException too broadly: a blanket catch around business logic that logs and swallows every failure, cancellation included, converts a clean cooperative stop into a logged error and can mask the fact that the operation never actually observed the token and threw for an unrelated reason that merely resembles cancellation. Long synchronous loops or CPU-bound work between await points need explicit polling, since there is nothing else to yield control back to the scheduler or notice the flag — a tight loop processing a large in-memory batch with no await inside it runs to completion regardless of cancellation unless it checks the token itself. Finally, cancellation should never be the sole way to signal ordinary business failure; OperationCanceledException should mean "this was asked to stop," not "validation failed," or downstream code that specifically handles cancellation as a non-error will misclassify a real bug as a graceful stop.
What interviewers look for: Checklist-level fluency in where cancellation silently breaks, especially the "token accepted but never passed onward" pattern, which is by far the most common real bug.
Common mistakes:
- Accepting a
CancellationTokenparameter purely to satisfy an interface or an analyzer, then never using it. - Wrapping cancellation-aware code in a broad try/catch that treats
OperationCanceledExceptionlike any other failure.
Q5 How does ASP.NET Core give you a cancellation token for the current HTTP request, and when should you use it?#
Short answer: HttpContext.RequestAborted is a CancellationToken the framework cancels when the client disconnects or the request otherwise ends abnormally, and you use it to stop doing work — database calls, downstream HTTP calls, expensive computation — that nobody will ever see the result of once the client is gone.
app.MapGet("/reports/{id}", async (int id, ReportStore store, CancellationToken cancellationToken) =>
{
// Minimal API model-binds RequestAborted directly as a CancellationToken parameter.
var report = await store.LoadAsync(id, cancellationToken);
return report is null ? Results.NotFound() : Results.Ok(report);
});Minimal APIs and MVC controller actions both support binding a CancellationToken parameter directly to HttpContext.RequestAborted — you rarely need to reach into HttpContext manually. Passing that token through to every downstream async call means a client that navigates away, closes a tab, or times out its own HTTP client stops consuming server resources for that request almost immediately, rather than running the full pipeline to completion for an audience that no longer exists. The main judgment call is where not to use it: a request that triggers a side effect which must complete regardless of the client staying connected, such as charging a payment or writing an audit record, should not be tied to RequestAborted, or a disconnect partway through could abandon that side effect in an inconsistent state. For that kind of work, either use CancellationToken.None deliberately for the critical section, or hand it off to a background queue that outlives the request entirely. RequestAborted composes with linked tokens exactly like any other token — wrapping it with an internal timeout is a common pattern for enforcing a maximum request-handling time independent of client behavior.
What interviewers look for: Knowing both when to use RequestAborted and, just as importantly, when deliberately not to, since propagating it everywhere is itself a bug for work that must survive a disconnect.
Follow-up questions:
- How would this differ for a gRPC service instead of a REST endpoint?
- What would you do if a long-running report generation should survive individual client disconnects but still be cancellable by an explicit "stop" action?
Q6 Walk through, in order, what happens when a .NET generic host receives SIGTERM.#
Short answer: The default host lifetime observes SIGTERM and calls IHostApplicationLifetime.StopApplication(), which triggers ApplicationStopping, then the host stops each registered IHostedService — including ASP.NET Core's own web host, which stops accepting new connections and drains existing ones — all bounded by the shutdown timeout, and finally triggers ApplicationStopped once everything has torn down.
public sealed class DrainingHostedService(ILogger<DrainingHostedService> logger) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public async Task StopAsync(CancellationToken cancellationToken)
{
// cancellationToken here is cancelled once the shutdown timeout has elapsed.
logger.LogInformation("Draining in-flight work before shutdown");
await DrainAsync(cancellationToken);
}
}IHostApplicationLifetime exposes three tokens — ApplicationStarted, ApplicationStopping, and ApplicationStopped — plus a StopApplication() method any component can call to request shutdown, not only the OS signal handler. Registering a callback on ApplicationStopping is the idiomatic place to start draining work or flipping a readiness flag before the process actually starts tearing down. Each IHostedService.StopAsync receives a CancellationToken that is already cancelled once the shutdown timeout has elapsed — the token is not "stop immediately," it is "you are now out of time," and well-written StopAsync implementations use it to abandon a graceful drain and force-close instead of hanging past the deadline. For an ASP.NET Core app, the web host's own hosted service stops the server during this sequence too: it stops accepting new connections and waits, up to the shutdown timeout, for in-flight requests on existing connections to complete.
What interviewers look for: The correct ordering — ApplicationStopping before services stop, services stopping within the timeout budget, ApplicationStopped last — and that the StopAsync token signals "timeout expired," not "stop now."
Q7 How do you configure the host shutdown timeout, and what happens if a hosted service ignores it?#
Short answer: HostOptions.ShutdownTimeout sets how long IHost.StopAsync waits for all hosted services to stop, defaulting to 30 seconds; if a service's StopAsync does not return within that window, the host stops waiting and the process shuts down anyway, tearing down whatever that service was still doing.
builder.Services.Configure<HostOptions>(options =>
{
options.ShutdownTimeout = TimeSpan.FromSeconds(45);
});The timeout is a budget shared across every IHostedService, not per service — if you register several hosted services and each needs meaningful drain time, they are all racing against the same clock, running their StopAsync methods concurrently, not sequentially with individually reset timers. Increasing the timeout is only half the fix; it has to be coordinated with whatever is orchestrating the process externally. Raising ShutdownTimeout to 45 seconds while a container platform force-kills the process after 30 accomplishes nothing, because the harder external deadline wins regardless of what the .NET host is configured to wait for. A StopAsync that ignores its CancellationToken entirely and just awaits a long-running drain unconditionally does not get "extra time" — the host still returns control once the timeout fires and moves on, so the ignored work either gets abruptly terminated by the process exiting or, worse, keeps running orphaned, which is exactly the kind of bug that produces a slow, mysterious shutdown in production.
What interviewers look for: Understanding that the timeout is shared and races hosted services concurrently, and that raising it in .NET configuration without also raising the platform's own kill timeout is a half-fix.
Common mistakes: Assuming a longer ShutdownTimeout alone guarantees a graceful drain without checking the platform's own termination grace period.
Q8 How do you achieve zero-downtime graceful shutdown for a .NET service running in Kubernetes?#
Short answer: Align three independent timers — a preStop hook plus terminationGracePeriodSeconds, the pod's readiness probe, and the host's own ShutdownTimeout — so the service stops receiving new traffic before it starts refusing it, and finishes its drain comfortably inside the time Kubernetes gives it before force-killing the process.
Kubernetes does not wait for a pod to become unready before sending SIGTERM; it sends SIGTERM and begins removing the pod from service endpoints at roughly the same time, which leaves a brief window where new requests can still land on a pod that is already shutting down. The standard mitigation is a preStop hook that sleeps for a few seconds before SIGTERM is even delivered, giving the surrounding networking layer time to stop routing new traffic first. terminationGracePeriodSeconds is the hard outer limit: Kubernetes sends SIGTERM, waits up to that many seconds, and force-kills the process unconditionally if it has not exited. The .NET host's ShutdownTimeout, plus the preStop sleep, must both fit comfortably inside that budget, with margin — if the grace period is 30 seconds and the preStop hook sleeps for 5, the .NET host effectively has about 25 seconds, not 30, to finish ApplicationStopping and stop every hosted service. On the application side, flipping the readiness probe to fail as soon as ApplicationStopping fires reinforces the preStop delay by telling Kubernetes' own health checking to stop sending traffic even faster. See running .NET on Kubernetes for how liveness and readiness probes are typically wired for this.
What interviewers look for: Knowing that SIGTERM and endpoint removal are not perfectly synchronized — the preStop sleep pattern specifically — and that all three timers have to be sized together, not independently.
Follow-up questions:
- What would you observe in request logs if
terminationGracePeriodSecondswere set too low for the service's actual drain time? - How does this change for a service behind a service mesh sidecar instead of talking to the platform's networking layer directly?
Q9 What is the difference between a timeout that cancels an operation and one that merely stops waiting for it?#
Short answer: A timeout that cancels an operation is wired into the operation's own CancellationToken, so the operation itself stops doing work; a timeout that merely stops waiting, such as a bare Task.WaitAsync(TimeSpan) on a token-unaware task, only ends the caller's patience while the original work keeps running unseen in the background.
This distinction matters most for resource cleanup. If a database call is still running after a caller gives up on it via WaitAsync alone, the connection, the transaction, and any locks it holds are all still live — the caller moved on, but the resource cost did not go anywhere. Repeated timeouts of this shape are a classic cause of connection pool exhaustion that looks, from the outside, like the database itself is slow, when the real problem is a growing set of abandoned-but-still-running calls. A timeout that actually cancels the operation needs the token threaded all the way down to whatever can stop the work, so that when the timeout fires, the underlying resource is genuinely released rather than merely abandoned by the caller. The safest pattern combines both deliberately: thread a timeout-linked token into the operation so it can clean up after itself, and still wrap the await in WaitAsync as a backstop in case some part of the call chain does not honor the token promptly — accepting that the backstop alone does not fix resource leaks, it only bounds how long the caller waits.
What interviewers look for: Connecting this distinction to a concrete failure mode, such as connection pool exhaustion or leaked locks, rather than restating the API difference in the abstract.
Q10 Describe a production incident that mishandled cancellation or shutdown could cause, and how you would design to prevent it.#
Short answer: A common pattern is a background worker that catches OperationCanceledException broadly during shutdown, logs it as an unhandled error, and trips an alert or a restart right as the process is exiting normally — turning a clean shutdown into a noisy incident; prevention means letting cancellation propagate as cancellation and testing the shutdown path deliberately, not just the happy path.
The generic shape: a hosted service's long-running loop awaits work with the host's stopping token, catches exceptions broadly to "be resilient," and does not special-case OperationCanceledException. On shutdown, every in-flight await throws that exception as expected, the broad catch logs it as an error and perhaps increments a failure metric, and if anything downstream reacts to that metric — an alert, an auto-restart policy — the team ends up investigating a "failure" that was actually the process exiting correctly, on every single deploy. A related variant: a shared resource such as a database connection pool gets torn down by the host's shutdown sequence while a hosted service's StopAsync is still using it, because that service did not respect the shutdown timeout and was still running when teardown continued around it, producing an exception that looks unrelated to shutdown timing unless you already suspect the ordering. Prevention is concrete: special-case OperationCanceledException in any catch block that wraps cancellation-aware code so it logs at a lower severity and does not drive alerting; explicitly exercise the shutdown path in a staging environment by sending the same signal the platform sends and watching for exactly this pattern; and keep every StopAsync implementation comfortably inside the configured shutdown timeout.
What interviewers look for: A believable, specific failure chain rather than a generic "test your code" answer, plus a concrete prevention step tied to the root cause, not just "add more logging."
Quick-Fire Round#
| Question | Answer |
|---|---|
Does CancellationTokenSource.Cancel() stop execution immediately? | No, it only requests cancellation. |
| Method to combine an external token with an internal timeout? | CreateLinkedTokenSource. |
Does Task.WaitAsync(TimeSpan) cancel the wrapped task? | No, only if that task also observes a token. |
Default HostOptions.ShutdownTimeout? | 30 seconds. |
| Token that signals shutdown has begun for hosted services? | IHostApplicationLifetime.ApplicationStopping. |
| ASP.NET Core token for the current HTTP request? | HttpContext.RequestAborted. |
| Signal Kubernetes sends before force-killing a pod? | SIGTERM, then SIGKILL after the grace period. |
| Kubernetes setting for the hard shutdown deadline? | terminationGracePeriodSeconds. |
How to Prepare#
- Practice tracing the exact sequence from
SIGTERMto process exit for a generic host, including which timeout governs which step. - Be ready to explain why
WaitAsyncalone does not cancel the wrapped operation, with a concrete resource-leak example. - Review .NET Generic Host: Configuration, Options and Logging and Running .NET on Kubernetes so the host and platform layers connect in your answer.
- Know all three
IHostApplicationLifetimetokens and what each one is for. - Rehearse a generic production incident caused by mishandled cancellation, with a specific fix, not just a description of the bug.