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
  • Job remains active after cancellation request
  • Memory leak from coroutine holding references after it should be cancelled
  • finally block 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 Exception or Throwable without checking for CancellationException
  • Using runCatching which swallows CancellationException
  • Error handling wrapper that does not re-throw cancellation
  • try-finally where the catch block absorbs cancellation
  • Retrofit/Room suspension functions that throw cancellation

Step-by-Step Fix

  1. 1.Always re-throw CancellationException:
  2. 2.```kotlin
  3. 3.suspend fun loadData() {
  4. 4.try {
  5. 5.val data = repository.fetchData()
  6. 6.updateUI(data)
  7. 7.} catch (e: CancellationException) {
  8. 8.throw e // CRITICAL: re-throw cancellation
  9. 9.} catch (e: Exception) {
  10. 10.logError(e)
  11. 11.showError(e.message)
  12. 12.}
  13. 13.}
  14. 14.`
  15. 15.**Use ensureActive() for explicit cancellation checks**:
  16. 16.```kotlin
  17. 17.suspend fun processLargeDataset(items: List<Item>) {
  18. 18.items.forEach { item ->
  19. 19.coroutineContext.ensureActive() // Check cancellation each iteration
  20. 20.processItem(item)
  21. 21.}
  22. 22.}
  23. 23.`
  24. 24.**Use isActive check in long-running loops**:
  25. 25.```kotlin
  26. 26.suspend fun monitorData() = coroutineScope {
  27. 27.while (isActive) {
  28. 28.val data = fetchLatest()
  29. 29.process(data)
  30. 30.delay(1000) // delay() is cancellable
  31. 31.}
  32. 32.}
  33. 33.`
  34. 34.**Use NonCancellable in finally blocks that need cleanup**:
  35. 35.```kotlin
  36. 36.suspend fun withCleanup() {
  37. 37.try {
  38. 38.val connection = openConnection()
  39. 39.connection.useData()
  40. 40.} catch (e: CancellationException) {
  41. 41.throw e
  42. 42.} catch (e: Exception) {
  43. 43.handleError(e)
  44. 44.} finally {
  45. 45.// This runs even after cancellation
  46. 46.withContext(NonCancellable) {
  47. 47.cleanupResources() // Won't be cancelled mid-cleanup
  48. 48.}
  49. 49.}
  50. 50.}
  51. 51.`
  52. 52.**Use supervisorScope to isolate failures**:
  53. 53.```kotlin
  54. 54.suspend fun loadMultiple() = supervisorScope {
  55. 55.val userDeferred = async { userRepository.getUser() }
  56. 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 CancellationException without re-throwing
  • Use coroutineScope or supervisorScope instead of raw try-catch
  • Add lint rule: catch (e: Exception) must check e is CancellationException
  • Use kotlinx.coroutines structured concurrency patterns
  • Test cancellation behavior in unit tests with runTest
  • Use viewModelScope and lifecycleScope which auto-cancel on lifecycle events