Cancellation Is Cooperative — And Your Code Isn't Cooperating
cancel() doesn't stop a coroutine — it sets a flag that only suspension points check. The four places Android code breaks the chain: CPU loops, catch(Exception), suspending cleanup in finally, and blocking Java calls.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — cancel() marks a Job cancelled; it does not stop execution. Only suspension points throw
CancellationException, so code that never suspends never notices. Four common patterns break
cooperation: tight CPU loops (fix with ensureActive()), catch (e: Exception) swallowing
CancellationException (rethrow it), suspending cleanup in finally (needs NonCancellable), and
blocking Java calls (need explicit interruption). Cooperation is a property of the code you write, not
of the framework.
What cancel() actually does
It transitions the Job to cancelling, and it makes every subsequent suspension point in that
coroutine throw CancellationException. That's the whole mechanism.
Which means this coroutine is uncancellable:
val job = scope.launch(Dispatchers.Default) {
var result = 0L
repeat(Int.MAX_VALUE) { i ->
result += expensiveHash(i) // no suspension point anywhere
}
log(result)
}
job.cancel()
job.join() // waits for the entire loop to finish
job.isActive is false immediately. The work keeps running, holding a thread, until it completes.
On Android, the version of this that reaches production is usually image processing, a large JSON
parse, or a decryption loop.
The fix is to check:
repeat(Int.MAX_VALUE) { i ->
ensureActive() // throws if cancelled
result += expensiveHash(i)
}
ensureActive() is cheap — a volatile read. Call it per iteration for coarse work, or every N
iterations for a very tight loop. Use yield() instead when you also want to let other coroutines on
that dispatcher make progress; it checks cancellation and reschedules.
If the loop calls a suspend function from kotlinx.coroutines — delay, withContext, a Flow
emit — you already have a check, because every one of those is cancellable by contract.
The bug that hides everywhere: catch (e: Exception)
CancellationException extends IllegalStateException, which extends Exception. So this
well-intentioned code breaks cancellation:
suspend fun load(): Data? = try {
api.fetch()
} catch (e: Exception) {
logger.warn("fetch failed", e) // also catches cancellation
null
}
When the scope is cancelled mid-fetch, this catches the cancellation, logs it as a network failure,
and returns null. The caller carries on and does more work inside a coroutine that is supposed to be
dead. In a ViewModel this shows as work continuing after onCleared(); in a retry loop it shows as
an infinite retry that no longer responds to cancellation at all.
Three acceptable fixes, in order of preference:
// 1. Catch what you actually meant
catch (e: IOException) { … }
// 2. Rethrow cancellation explicitly
catch (e: Exception) {
if (e is CancellationException) throw e
logger.warn("fetch failed", e); null
}
// 3. currentCoroutineContext().ensureActive() before handling
Option 1 is right almost always. Option 2 exists for genuine catch-all boundaries — a top-level error
handler, a plugin host. Any catch (e: Exception) inside a suspend function is worth a second look
in review; it is the single most common way teams break cancellation without noticing.
runCatching has the same problem, for the same reason. It catches Throwable.
Cleanup in finally can't suspend
A cancelled coroutine cannot suspend again — every suspension point immediately throws. So this silently doesn't run:
try {
stream.collect { … }
} finally {
api.releaseLock(id) // suspend fun — throws instantly on cancel
}
Non-suspending cleanup (close(), unregistering a listener) is fine in finally. Suspending cleanup
needs an explicit opt-out:
} finally {
withContext(NonCancellable) {
api.releaseLock(id)
}
}
Two rules for NonCancellable: keep it to the smallest possible block, and give it a timeout if it
touches the network. It disables the mechanism that stops runaway work, so a NonCancellable block
wrapped around a long operation is how a "cancelled" screen keeps working for thirty seconds.
Blocking Java calls don't know about coroutines
Thread.sleep(), InputStream.read(), a JDBC call, an OkHttp execute() — none of them check a
coroutine's Job. Dispatchers.IO gives you a thread pool, not interruptibility.
For anything with a real cancellation API, bridge it:
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, r: Response) = cont.resume(r)
override fun onFailure(call: Call, e: IOException) = cont.resumeWithException(e)
})
cont.invokeOnCancellation { cancel() } // this is the important line
}
invokeOnCancellation is where a coroutine's cancellation becomes the library's cancellation. Without
it you get a coroutine that returns promptly while the request keeps running.
Retrofit, Room and the Ktor client already do this correctly. Hand-rolled bridges to legacy SDKs frequently don't — that's where to look when a cancelled screen keeps making calls.
How to see it
// In tests: assert cancellation actually propagates
@Test fun cancelStopsWork() = runTest {
val started = CompletableDeferred<Unit>()
val job = launch { started.complete(Unit); doWork() }
started.await()
job.cancelAndJoin()
assertFalse(sideEffects.stillRunning)
}
And in production, log at cancellation boundaries rather than guessing. A ViewModel whose network
calls outlive onCleared() is the observable symptom of every bug above.
The rule
Cancellation is a request; cooperation is your code's responsibility. Every suspend function you
write either forwards that request or breaks the chain for everything beneath it — and the framework
cannot tell the difference for you.