Introduction
When a Kotlin coroutine is cancelled, it throws a CancellationException. If your code catches Exception (or Throwable) without re-throwing CancellationException, the coroutine continues running instead of stopping. This violates structured concurrency principles and leads to resource leaks, zombie coroutines, and memory that is never freed.
Symptoms
- Coroutine does not stop when
cancel()is called Jobremains active after cancellation request- Memory leak from coroutine holding references after it should be cancelled
finallyblock does not execute because cancellation was swallowed- View lifecycle destroys but coroutine keeps running
Example problem: ```kotlin suspend fun loadData() { try { val data = repository.fetchData() updateUI(data) } catch (e: Exception) { // BUG: CancellationException is caught here and suppressed! logError(e) showError(e.message) } }
// When coroutine is cancelled: // - CancellationException is thrown by suspend functions // - It is caught by the catch block // - Coroutine continues instead of stopping ```
Common Causes
- Catching
ExceptionorThrowablewithout checking forCancellationException - Using
runCatchingwhich swallowsCancellationException - Error handling wrapper that does not re-throw cancellation
try-finallywhere the catch block absorbs cancellation- Retrofit/Room suspension functions that throw cancellation
Step-by-Step Fix
- 1.Always re-throw CancellationException:
- 2.```kotlin
- 3.suspend fun loadData() {
- 4.try {
- 5.val data = repository.fetchData()
- 6.updateUI(data)
- 7.} catch (e: CancellationException) {
- 8.throw e // CRITICAL: re-throw cancellation
- 9.} catch (e: Exception) {
- 10.logError(e)
- 11.showError(e.message)
- 12.}
- 13.}
- 14.
` - 15.**Use
ensureActive()for explicit cancellation checks**: - 16.```kotlin
- 17.suspend fun processLargeDataset(items: List<Item>) {
- 18.items.forEach { item ->
- 19.coroutineContext.ensureActive() // Check cancellation each iteration
- 20.processItem(item)
- 21.}
- 22.}
- 23.
` - 24.**Use
isActivecheck in long-running loops**: - 25.```kotlin
- 26.suspend fun monitorData() = coroutineScope {
- 27.while (isActive) {
- 28.val data = fetchLatest()
- 29.process(data)
- 30.delay(1000) // delay() is cancellable
- 31.}
- 32.}
- 33.
` - 34.**Use
NonCancellablein finally blocks that need cleanup**: - 35.```kotlin
- 36.suspend fun withCleanup() {
- 37.try {
- 38.val connection = openConnection()
- 39.connection.useData()
- 40.} catch (e: CancellationException) {
- 41.throw e
- 42.} catch (e: Exception) {
- 43.handleError(e)
- 44.} finally {
- 45.// This runs even after cancellation
- 46.withContext(NonCancellable) {
- 47.cleanupResources() // Won't be cancelled mid-cleanup
- 48.}
- 49.}
- 50.}
- 51.
` - 52.**Use
supervisorScopeto isolate failures**: - 53.```kotlin
- 54.suspend fun loadMultiple() = supervisorScope {
- 55.val userDeferred = async { userRepository.getUser() }
- 56.val settingsDeferred = async { settingsRepository.getSettings() }
// If one fails, the other continues val user = userDeferred.await() val settings = settingsDeferred.await()
updateUI(user, settings) } ```
Prevention
- Never catch
CancellationExceptionwithout re-throwing - Use
coroutineScopeorsupervisorScopeinstead of rawtry-catch - Add lint rule:
catch (e: Exception)must checke is CancellationException - Use
kotlinx.coroutinesstructured concurrency patterns - Test cancellation behavior in unit tests with
runTest - Use
viewModelScopeandlifecycleScopewhich auto-cancel on lifecycle events