Cancellation is cooperative (and your cleanup is cancelled too)
cancel() does not stop anything. It sets a flag and lets the next suspension point throw — which is why a coroutine that never suspends is a coroutine you cannot stop.
Explore the full explanation 8 ideas
Cancellation is the feature that makes structured concurrency worth having: when a WebFlux client disconnects, when a `withTimeout` fires, when an Android screen is destroyed, the work underneath should stop — and stop *everywhere*, all the way down the tree of children. But the mechanism is not what most people assume. `job.cancel()` does not interrupt a thread and it does not abort a computation. It moves the Job into a *cancelling* state, and from that moment every **suspension point** inside that coroutine throws a `CancellationException` instead of resuming normally. Cancellation is delivered, not enforced.
The immediate consequence is that a coroutine which never suspends can never be cancelled. A tight loop doing CPU work — parsing a large file, running a simulation, hashing — has no suspension points, so `cancel()` sets the flag and the loop keeps running to completion regardless. The fix is to make the loop cooperate: call `ensureActive()` (which throws if the Job is cancelled), or check `isActive` yourself, or call `yield()` (which both checks for cancellation and gives other coroutines on the dispatcher a turn). Every suspending function in `kotlinx.coroutines` — `delay`, `await`, `Channel.send`, `Mutex.lock` — already checks for you; it is only *your own* non-suspending work that has to opt in.
The second consequence is the one that causes production bugs: a cancelled coroutine is *already* in the cancelling state when its `finally` block runs. Every suspension point in that block therefore throws immediately — so a cleanup like `finally { connection.closeAsync(); metrics.flush() }` written with suspending calls never executes a single line of it. The block runs, the first `delay`/`await` inside it throws, and the resource leaks. This is not a bug in the library; it is the cancellation contract applied consistently, and it surprises everyone exactly once.
`withContext(NonCancellable) { … }` is the escape hatch. It installs a Job that is never cancelled, so suspensions inside it behave normally, and it is the *only* legitimate use of `NonCancellable`: a short, bounded cleanup block. Wrapping business logic in it converts a cancellable coroutine into an unstoppable one, which is strictly worse than not supporting cancellation at all — the caller now believes it can stop the work and cannot. Keep the block small enough that you would be comfortable with it running to completion after a shutdown signal, because that is exactly what it will do.
`CancellationException` is also the reason broad exception handling is dangerous inside coroutines. It is an ordinary `Exception`, so `catch (e: Exception)` catches it, and so does `runCatching { }` (which catches `Throwable`). A retry wrapper written that way keeps looping after `cancel()`, producing a **zombie coroutine**: the parent scope has moved on, nobody will ever join it, and structured concurrency's central guarantee — when the scope returns, its children are finished — is quietly false. In a request-scoped server that is one leaked coroutine per cancelled request. The rule is absolute: either catch the specific exception you actually handle, or catch broadly and rethrow `CancellationException` first.
Cancellation propagates down and, for failures, up. Cancelling a parent cancels every child; a child that *fails* cancels its parent (which then cancels the siblings) unless the parent is a `SupervisorJob`. This is why `coroutineScope` and `supervisorScope` are a design decision rather than a style preference: the first says "these results only make sense together", the second says "these are independent best-effort tasks". Note the asymmetry — supervision stops *failure* travelling upward, but a cancelled supervisor still cancels its children. Supervision is not immunity.
A few APIs are worth knowing precisely. `cancel()` only *requests* cancellation and returns immediately, so reading state right after it is a race; `cancelAndJoin()` waits for the coroutine to finish unwinding. `withTimeout(t)` throws `TimeoutCancellationException` (a `CancellationException`, so it cancels the block rather than escaping as a normal error) while `withTimeoutOrNull(t)` returns `null` instead. `job.invokeOnCompletion { cause -> … }` gives you a non-suspending callback that runs on every terminal path, which is often a better place for bookkeeping than a `finally` that has to fight the cancellation contract.
The mental model to keep: cancellation is a *cooperative protocol*, not a kill switch. Your code participates by suspending often enough to notice, by not swallowing the signal, and by making its cleanup explicitly non-cancellable for the few milliseconds that takes. Code that does all three can be stopped instantly and leaves nothing behind; code that does none of them looks fine in tests and leaks under load, because it is only under load that anything actually gets cancelled.
Let’s figure it out.
Ask me about this course or where to start.
import kotlinx.coroutines.*suspend fun stubborn() = coroutineScope {val job = launch(Dispatchers.Default) {var i = 0// No suspension point in here, so cancel() cannot be observed.while (i < 50_000_000) i++println("stubborn finished anyway: " + i)}delay(20)job.cancelAndJoin()}suspend fun cooperative() = coroutineScope {var seen = 0val job = launch(Dispatchers.Default) {try {var i = 0while (i < 50_000_000) {ensureActive() // throws once the Job is cancellingi++seen = i}} finally {// The job is ALREADY cancelling here, so a suspending cleanup// needs a Job that is not.withContext(NonCancellable) {delay(5)println("cleanup ran; stopped early = " + (seen < 50_000_000))}}}delay(20)job.cancelAndJoin()}fun main() = runBlocking {stubborn()cooperative()}
The three shapes side by side: a CPU loop that ignores cancellation, the cooperative version, and a suspending cleanup that only runs because it opted out of the cancelled Job.
Arena IDEimport kotlinx.coroutines.*suspend fun zombie() = coroutineScope {var ticks = 0val job = launch {repeat(20) {try {delay(10)ticks++} catch (e: Exception) {// WRONG: CancellationException is an Exception.// The loop keeps going after cancel().}}}delay(25)job.cancelAndJoin()println("zombie kept ticking to " + ticks)}suspend fun wellBehaved() = coroutineScope {var ticks = 0val job = launch {repeat(20) {try {delay(10)ticks++} catch (e: CancellationException) {throw e // never ours to handle} catch (e: Exception) {// real, retryable failures only}}}delay(25)job.cancelAndJoin()println("well-behaved stopped at " + ticks)}fun main() = runBlocking {zombie()wellBehaved()}
The swallowed-cancellation bug and its two correct forms. A broad catch inside a coroutine must let CancellationException through, or the loop becomes a zombie that outlives its scope.
Arena IDEimport kotlinx.coroutines.*suspend fun slowCall(): String {delay(200)return "done"}fun main() = runBlocking {// Throws TimeoutCancellationException, which IS a CancellationException.val thrown = try {withTimeout(50) { slowCall() }} catch (e: TimeoutCancellationException) {"timed out: " + e::class.simpleName}println(thrown)// Same cancellation, different reporting.println("orNull = " + withTimeoutOrNull(50) { slowCall() })// And it does finish when it fits inside the budget.println("in budget = " + withTimeoutOrNull(500) { slowCall() })}
Deadlines: withTimeout throws a CancellationException (so it cancels the block), withTimeoutOrNull hands you null instead. Both cancel the work — the difference is only how you find out.
Arena IDE@RestControllerclass ReportController(private val reports: ReportService) {// Spring cancels this coroutine when the HTTP client goes away.@GetMapping("/reports/{id}")suspend fun report(@PathVariable id: Long): Report =withTimeout(3_000) { reports.build(id) }}@Serviceclass ReportService(private val client: WebClient) {suspend fun build(id: Long): Report {val lease = acquireLease(id) // a real resourcetry {return client.get().uri("/raw/{id}", id).retrieve().awaitBody()} finally {// Without NonCancellable this releases NOTHING on the cancelled// path — i.e. on every client disconnect and every timeout.withContext(NonCancellable) { lease.release() }}}}
In a WebFlux handler you get cancellation for free when the client disconnects — which is exactly when a leaked resource costs you. Reference only (needs Spring).
🧠 Check your understanding
0/1 · 0/1 answered1. A coroutine is cancelled while it is inside a `try` block. Its `finally` block calls a suspending `flushMetrics()`. What happens?