
Lessons from a production Android codebase
Cancellation in Kotlin Coroutines looks simple: cancel the Job, and the coroutine stops. In production, it is rarely that clean.
Cancellation is cooperative. Every suspend function, exception handler, callback bridge and child scope must carry the signal. Break one boundary, and work may continue after its owner is gone.
We saw the result in production: false error logs, empty results, stale Bluetooth work and a final save lost during logout. Nothing crashed. The app simply continued with the wrong state.
Cancellation is cooperative
Kotlin uses CancellationException to stop a coroutine. Functions such as delay and await check the Job and throw when it is cancelled.
val job = scope.launch {
delay(5_000)
updateState()
}
job.cancel()
Here, delay observes cancellation, so updateState is not called. Regular Kotlin code does not check automatically. It keeps running until the next cancellation point.
When cancellation becomes a failure
return try {
repository.fetchNotifications()
} catch (error: Exception) {
logger.error("Failed to load notifications", error)
emptyList()
}
If the user leaves the screen, viewModelScope is cancelled and fetchNotifications throws CancellationException. The broad catch records a normal lifecycle event as a failure. It also returns emptyList, so the caller cannot distinguish cancellation from a successful query with no data.
runCatching has the same problem because it also catches CancellationException. Rethrow cancellation before handling other failures.
catch (error: CancellationException) {
throw error
} catch (error: Exception) {
logger.error("Failed to load notifications", error)
emptyList()
}
In production, normal navigation becomes error telemetry. Empty values can appear as missing documents, notifications or time cards. Support sees missing data while the app reports a completed request.
Callback bridges can outlive the caller
suspend fun loadMessages() = suspendCoroutine { continuation ->
chatClient.loadMessages { messages ->
continuation.resume(messages)
}
}
The function is suspend, but the bridge is not cancellable. If the SDK never calls back, the coroutine keeps waiting after its Job is cancelled.
Use suspendCancellableCoroutine and cancel the underlying request. A cancellable continuation alone is not enough; an Rx subscription or SDK callback can still run in the background.
suspendCancellableCoroutine { continuation ->
val request = chatClient.loadMessages { messages ->
if (continuation.isActive) {
continuation.resume(messages)
}
}
continuation.invokeOnCancellation {
request.cancel()
}
}
In production, chat, form or document work may outlive its screen and keep memory, network or database resources. The app remains open, but one workflow can hang or show stale state.
Blocking code can ignore cancellation
withContext(Dispatchers.IO) {
repository.loadNotifications().blockingGet()
}
Dispatchers.IO moves the blocking call off the main thread. It does not make that call cancellable. The thread stays occupied until blockingGet returns.
Prefer a suspend API such as await. If unavoidable JVM code responds to thread interruption, runInterruptible can connect it to coroutine cancellation.
In production, the work continues after its result is useless and consumes IO capacity. If a blocking boundary reaches the main thread, it can become an ANR.
Work with no owner
fun onPacketReceived(bytes: ByteArray) {
CoroutineScope(ioDispatcher).launch {
packetChannel.send(Packet(bytes))
}
}
Each packet creates a scope that is immediately discarded. Nothing can cancel the work on disconnect. Independent coroutines may also try to send at the same time.
Use one scope owned by the connection manager, then serialize sends with a Mutex.
private val packetSendMutex = Mutex()
fun onPacketReceived(bytes: ByteArray) {
ownerScope.launch {
packetSendMutex.withLock {
packetChannel.send(Packet(bytes.copyOf()))
}
}
}

The second coroutine waits while the first send holds the mutex
The mutex allows only one send at a time, even when send suspends. It does not preserve callback order: packet 2 may reach the lock first. If the protocol requires strict arrival order, enqueue packets before launching asynchronous work.
In production, work can continue after disconnect, and concurrent state changes can affect reconnect. The user sees an unreliable Bluetooth connection long after the callback that caused it.
Timeout as normal completion
withTimeout(timeout) {
startBluetoothDiscovery()
delay(timeout)
}
withTimeout ends by throwing TimeoutCancellationException. When timeout is expected, withTimeoutOrNull makes the result explicit. A callbackFlow should also use awaitClose to unregister the receiver when collection ends.
val device = withTimeoutOrNull(timeout) {
discoverDevice()
}
if (device == null) stopDiscovery()
In production, discovery can stop without a clear result or recovery action. An expected timeout then looks like a connection failure.
Cancellation at the final step
During an account switch, the old user’s tokens had to be saved before logout changed the session. The original flow launched the save, then dispatched logout.
viewModelScope.launch {
tokenStore.save(currentTokens)
}
dispatchLogout()

Logout cancels the scope before the token save completes
Keep both operations in the same coroutine. tokenStore.save suspends, so logout is dispatched only after the save completes.
viewModelScope.launch {
tokenStore.save(currentTokens)
dispatchLogout()
}
In production, a logout race can lose authentication state or unsynced data. The failure appears in the next session, far from the cancellation that caused it.
A cancelled scope stays cancelled
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
fun clear() = scope.cancel()
fun startAgain() = scope.launch {
syncData()
}
After clear, `startAgain()` launches into a cancelled scope, so syncData never runs. If the object is reusable, cancel its children or create a new scope for the next lifecycle.
private val parentJob = SupervisorJob()
private val scope = CoroutineScope(parentJob + dispatcher)
fun clear() = parentJob.cancelChildren()
fun startAgain() = scope.launch {
syncData()
}
cancelChildren stops the current work but leaves parentJob active, so later launches can run. If clear ends the object’s lifecycle, keep scope.cancel() and create a new owner for the next lifecycle.
In production, one background subsystem can stop while the rest of the app remains healthy. Missing backend data appears later, with no crash pointing to the cause.
What these bugs cost
Cancellation bugs create two costs. False failures pollute reliability data. Silent cancellations stop one workflow while the rest of the app continues.
They also separate cause from symptom. A scope is cancelled during logout; the missing state appears at the next login. A packet survives disconnect; the failure appears during reconnect.
A static review cannot prove that a pattern caused an incident. It can show where the same failure is possible and where runtime tracing should begin.
Reviewing cancellation in production
- Can this catch receive CancellationException?
- Can cancellation return a valid-looking value?
- Who owns this scope, and when is it cancelled?
- Does cancellation stop the underlying callback or subscription?
- Does important work depend on a screen scope surviving?
- Can this object reuse a scope after cancelling it?
- Is blocking code hidden inside a coroutine?
- Is timeout an error or normal completion?
- Does serialization preserve the order required by the protocol?
References
Kotlin cancellation and timeouts; Kotlin suspendCancellableCoroutine; Kotlin callbackFlow; Kotlin channels; Kotlin limitedParallelism; Android coroutine best practices
The Ghost Work Left Behind by Cancelled Coroutines was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.