5 min read

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.

KotlinCoroutinesAndroidConcurrencyCancellation
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DRcancel() 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.coroutinesdelay, 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.

Keep reading