Coroutine Cancellation & Failure
Cancellation in coroutines works on a fundamentally different principle from interrupting a thread: it is not a forced yank out of running code but a cooperative protocol. When you call cancel(), you do not stop the coroutine — you move its Job into a cancelled state and rely on the code noticing that at the nearest suspension point. Every suspend function from kotlinx.coroutines throws a CancellationException on resumption; but a tight CPU loop without a single suspension will not notice the cancellation at all and will dutifully count all the way to the end, already cancelled. That is where half the traps of the topic come from.
The other half grows out of the fact that cancellation and failure propagate through the Job tree. Cancellation travels down, to the children, while a child's failure also travels up, cancelling the parent and with it every sibling — a consequence of structured concurrency, where the parent waits for all its children and answers for their failures. SupervisorJob cuts the upward path, leaving isolation in place; CoroutineExceptionHandler catches what was not caught inside. And above all of it sits the special status of CancellationException: it is not an error but the cancellation mechanism itself, which is why a broad catch or a runCatching that swallows it quietly breaks cancellation, and why a finally in a cancelled coroutine cannot make a single suspend call without NonCancellable. The layer-by-layer breakdown is below.
Topic map
- The Job hierarchy —
Jobas a lifecycle handle and a node of the tree, its states, cancellation propagating down and failure up, and the trap of a substitutedJobthat tears the tree apart. - Cooperative cancellation — why
cancel()only raises a flag, how suspension points throw on resumption, and howyield(),ensureActive()andisActivemake your loop cancellable. - Cancellation-safe cleanup — why a
CancellationExceptionmust not be swallowed, why a suspend call infinallyfails immediately, and whatwithContext(NonCancellable)is for. - SupervisorJob and failure isolation — an ordinary
Jobtakes every sibling down, aSupervisorJoblets a failure travel downward only, and two traps: the body ofsupervisorScopestill throws atawait(), and the supervisor must be the direct parent. - CoroutineExceptionHandler — the last line of defence, working only at the root of a scope, firing for
launchbut not forasync, and never catching a cancellation.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Thinking cancel() forcibly stops a coroutine | Cancellation is cooperative: cancel() only raises a flag, and the body stops only at a suspension point |
| Expecting a CPU loop with no suspend call to notice the cancellation | It counts to the end while already cancelled; you need yield(), ensureActive() or while (isActive) |
| Thinking the dispatcher "delivers" the cancellation | Cancellation is delivered by a suspension point or an explicit check, not by a thread switch |
Treating a Job as a holder of the result | A Job is a lifecycle handle with no value; the result is carried by the Deferred from async |
| Assuming a cancelled parent completes at once | The parent moves to Completed only once every one of its children has finished |
Passing a Job() into a child's context (launch(Job())) | The new Job replaces the parent link — the coroutine is orphaned, and neither cancellation nor failures reach it |
Wrapping a suspend call in catch (e: Exception) or runCatching | The CancellationException gets swallowed — cancellation breaks, the coroutine lives on, the parent hangs |
Reporting a CancellationException to the user as a failure | Cancellation is not an error; it must be rethrown, not logged |
Thinking finally is skipped on cancellation | finally does run, but any suspend call inside it fails immediately in a cancelled coroutine |
Doing suspend cleanup in finally without NonCancellable | The call fails instantly — the file is left open, the transaction uncommitted; you need withContext(NonCancellable) |
Putting long-running work inside NonCancellable | There is nothing left to stop it with — the section must be short and bounded |
Expecting a SupervisorJob to swallow a failure | It only isolates: the failure travels downward only, and reporting it is up to a handler or a try/catch |
Placing a SupervisorJob anywhere but as the direct parent of the failing coroutine | launch(SupervisorJob()) inside a scope is useless; a nested coroutineScope brings back all-or-nothing |
Expecting a try around launch to catch what the child throws | It catches nothing: the child fails later, not at the call site |
Installing a CoroutineExceptionHandler on a child | It is ignored: the handler fires only at the scope's root, where the failure surfaces |
Expecting a CoroutineExceptionHandler on a failure from async | async never calls it — the exception is stored in the Deferred and rethrown from await() |
Interview relevance
Coroutine cancellation is the section where the interviewer probes not your vocabulary but your execution model. One question separates the candidate who memorized from the one who understands: "what does cancel() physically do?". The right answer is not "it stops the coroutine" but "it moves the Job into a cancelled state and resumes suspension points with an exception; the body stops where it suspends or where it asks about its own state". Everything follows from that single model: why a CPU loop ignores cancellation, why catch (e: Exception) breaks it, why finally cannot close a resource without NonCancellable, and why one widget's failure takes the whole screen down.
Typical checks:
- What happens to the children when a
Jobis cancelled, and why the parent waits for them to finish. - Why cancellation is cooperative, and how to make a tight loop cancellable (
yield/ensureActive/isActive). - Why
CancellationExceptionis special, and what happens if you catch it with a broadcatch. - How to run suspend cleanup in a cancelled coroutine, and what the limits of
NonCancellableare. - How
SupervisorJobandsupervisorScopechange failure propagation, and where to place them. - Which coroutine
CoroutineExceptionHandlerfires on, and whyasyncnever calls it.
Common wrong answer: "cancel() stops the coroutine immediately, wherever it happens to be." A whole cluster of further errors grows out of that one: if it stops it by force, then the CPU loop will break on its own, finally can be written any way you like, and catch (e: Exception) is safe. In reality cancel() only raises a flag and resumes suspensions with an exception; a loop with no suspensions runs to completion, a suspend call in finally fails at once, and a swallowed CancellationException leaves the coroutine alive while the parent hangs waiting for it. The whole topic is about where exactly, and how exactly, cancellation "lands".