Skip to content

Fix: Kotlin Coroutine Scope Cancelled — JobCancellationException or Coroutine Not Running

FixDevs ·

Quick Answer

How to fix Kotlin coroutine cancellation issues — scope lifecycle, SupervisorJob, CancellationException handling, structured concurrency, viewModelScope, and cooperative cancellation.

The Problem

A coroutine stops executing unexpectedly with a JobCancellationException:

val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
    fetchData()   // Throws JobCancellationException — scope was cancelled
}

scope.cancel()   // Cancels all coroutines in this scope

// Later code trying to launch in the cancelled scope silently fails:
scope.launch {
    // This coroutine never runs
}

Or a child coroutine failure cancels the entire scope:

val scope = CoroutineScope(Dispatchers.IO)

scope.launch {
    throw RuntimeException("Something went wrong")
    // This exception propagates UP and cancels the entire scope
}

scope.launch {
    delay(100)
    println("This never runs")   // Scope was cancelled by the first coroutine
}

Or in Android, a viewModelScope coroutine gets cancelled when the screen is rotated:

class UserViewModel : ViewModel() {
    fun loadUser() {
        viewModelScope.launch {
            val user = repository.getUser()   // Cancelled on rotation
            _uiState.value = UiState.Success(user)
        }
    }
}

Or CancellationException is caught and swallowed, breaking structured concurrency:

launch {
    try {
        longRunningOperation()
    } catch (e: Exception) {
        // WRONG — catches CancellationException, prevents proper cancellation
        log.error("Error", e)
    }
}

Why This Happens

Kotlin coroutines use structured concurrency. A CoroutineScope creates a parent-child hierarchy. Understanding the rules:

  • Cancelling a scope cancels all childrenscope.cancel() propagates to every coroutine launched in that scope.
  • A child exception cancels the parent (with regular Job) — if a child coroutine throws an unhandled exception, it propagates to the parent Job, which cancels itself and all other children.
  • CancellationException is special — it’s used internally by the coroutine framework to implement cancellation. Catching Exception without rethrowing CancellationException breaks cancellation.
  • Cancelled scope can’t launch new coroutines — once a scope’s Job is in the Cancelled state, launch and async silently create coroutines that immediately cancel.
  • viewModelScope is tied to ViewModel lifecycle — it cancels when onCleared() is called (ViewModel is destroyed). Screen rotation in Android recreates the Activity but not the ViewModel — viewModelScope survives rotation.

Fix 1: Use SupervisorJob to Isolate Failures

With a regular Job, a child failure cancels all siblings. SupervisorJob lets sibling coroutines continue independently:

// PROBLEM — regular Job: one failure cancels everything
val regularScope = CoroutineScope(Dispatchers.IO + Job())

regularScope.launch { throw RuntimeException("Fetch failed") }
regularScope.launch {
    delay(100)
    println("Never runs — scope cancelled by sibling failure")
}

// FIX — SupervisorJob: failures are isolated to the failing child
val supervisorScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

supervisorScope.launch {
    throw RuntimeException("Fetch failed")
    // This fails but doesn't cancel the scope
}
supervisorScope.launch {
    delay(100)
    println("Runs fine — SupervisorJob isolates failures")
}

supervisorScope function for local isolation:

// supervisorScope block — children fail independently, parent waits for all
suspend fun loadDashboard() = supervisorScope {
    val users = async { fetchUsers() }    // If this fails...
    val posts = async { fetchPosts() }    // ...this still runs
    val stats = async { fetchStats() }    // ...and this too

    // Collect results — handle individual failures
    val usersResult = runCatching { users.await() }
    val postsResult = runCatching { posts.await() }
    val statsResult = runCatching { stats.await() }

    Dashboard(
        users = usersResult.getOrDefault(emptyList()),
        posts = postsResult.getOrDefault(emptyList()),
        stats = statsResult.getOrDefault(null),
    )
}

Fix 2: Handle CancellationException Correctly

CancellationException must never be silently swallowed:

// WRONG — catches and swallows CancellationException
launch {
    try {
        longOperation()
    } catch (e: Exception) {
        log.error("Error", e)
        // CancellationException caught here — cancellation broken
    }
}

// CORRECT — rethrow CancellationException
launch {
    try {
        longOperation()
    } catch (e: CancellationException) {
        throw e   // Always rethrow CancellationException
    } catch (e: Exception) {
        log.error("Error", e)
    }
}

// BETTER — catch only what you intend to handle
launch {
    try {
        longOperation()
    } catch (e: IOException) {
        log.error("Network error", e)   // Only handles network errors
    } catch (e: DatabaseException) {
        log.error("DB error", e)
    }
    // CancellationException propagates naturally — not caught
}

// ALSO CORRECT — use runCatching and re-throw cancellation
launch {
    runCatching { longOperation() }
        .onFailure { e ->
            if (e is CancellationException) throw e   // Rethrow cancellation
            log.error("Error", e)
        }
}

Fix 3: Check for Cancellation in Long-Running Operations

Long-running loops must periodically check if the coroutine is still active:

// PROBLEM — loop doesn't check for cancellation
launch {
    repeat(1_000_000) { i ->
        heavyComputation(i)
        // If scope is cancelled, this keeps running — ignores cancellation
    }
}

// FIX 1 — use yield() to check for cancellation and give up CPU
launch {
    repeat(1_000_000) { i ->
        heavyComputation(i)
        yield()   // Suspends, checks for cancellation, resumes if not cancelled
    }
}

// FIX 2 — check isActive explicitly
launch {
    var i = 0
    while (isActive && i < 1_000_000) {   // isActive = false when cancelled
        heavyComputation(i++)
    }
    // If cancelled, loop exits and coroutine ends cleanly
}

// FIX 3 — ensureActive() throws CancellationException if not active
launch {
    repeat(1_000_000) { i ->
        ensureActive()   // Throws CancellationException if cancelled
        heavyComputation(i)
    }
}

withContext and dispatcher switching respect cancellation automatically:

// suspend functions that use withContext check cancellation at suspension points
launch {
    val result = withContext(Dispatchers.IO) {
        // Database or network call — checks cancellation at each suspension
        database.query("SELECT * FROM users")
    }
    // If cancelled during the query, CancellationException is thrown here
    processResult(result)
}

Fix 4: Lifecycle-Aware Scopes in Android

Use the correct scope for each Android component:

// ViewModel — survives screen rotation, cancelled when ViewModel is destroyed
class UserViewModel : ViewModel() {
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val user = repository.getUser(id)
                _uiState.value = UiState.Success(user)
            } catch (e: CancellationException) {
                throw e   // Propagate cancellation
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e.message)
            }
        }
    }
}

// Fragment/Activity — cancelled when view is destroyed (NOT on rotation)
class UserFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        // viewLifecycleOwner.lifecycleScope — tied to view lifecycle
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                updateUI(state)   // Cancelled when fragment view is destroyed
            }
        }
    }
}

// For one-off operations in Activity/Fragment (not collection)
class UserActivity : AppCompatActivity() {
    fun triggerAction() {
        lifecycleScope.launch {
            // Cancelled when Activity is destroyed (not on rotation with recreate)
            performOneTimeAction()
        }
    }
}

Collect flows safely in Android:

// Collect StateFlow in lifecycle-aware manner
viewLifecycleOwner.lifecycleScope.launch {
    // repeatOnLifecycle — pauses collection when in background, resumes in foreground
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            render(state)
        }
    }
}

// Or use flowWithLifecycle extension
viewModel.uiState
    .flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    .onEach { state -> render(state) }
    .launchIn(viewLifecycleOwner.lifecycleScope)

Fix 5: Handle Coroutine Exceptions with CoroutineExceptionHandler

For top-level coroutines, use a CoroutineExceptionHandler to catch unhandled exceptions:

val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    when (throwable) {
        is CancellationException -> Unit  // Ignore — normal cancellation
        else -> {
            log.error("Unhandled coroutine exception", throwable)
            // Report to Crashlytics, Sentry, etc.
        }
    }
}

val scope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)

scope.launch {
    // If this throws, exceptionHandler catches it
    // Other coroutines in the scope continue (SupervisorJob)
    riskyOperation()
}

CoroutineExceptionHandler only works for top-level coroutines:

// WORKS — top-level launch
scope.launch(exceptionHandler) {
    throw RuntimeException("Caught by handler")
}

// DOESN'T WORK — nested launch
scope.launch {
    launch(exceptionHandler) {
        throw RuntimeException("NOT caught by handler — propagates to parent")
    }
}

// WORKS for nested — use try/catch or async/await
scope.launch {
    val result = runCatching {
        innerOperation()
    }
    result.onFailure { e ->
        if (e is CancellationException) throw e
        handleError(e)
    }
}

Fix 6: Cancellation in Ktor and Spring (Server-Side)

Server-side Kotlin also uses coroutines with lifecycle-tied scopes:

Ktor — request-scoped coroutines:

// Ktor — each request has its own coroutine scope
// The scope is cancelled when the client disconnects or response is sent
routing {
    get("/data") {
        // This coroutine is tied to the request
        val data = fetchData()   // If client disconnects, this is cancelled
        call.respond(data)
    }

    // For background work that should outlive the request:
    get("/trigger-job") {
        val jobScope = application.coroutineContext   // Application-level scope
        jobScope.launch {
            longRunningBackgroundJob()   // Not cancelled with request
        }
        call.respond("Job started")
    }
}

Spring — coroutine support in WebFlux:

@RestController
class UserController(private val userService: UserService) {

    @GetMapping("/users/{id}")
    suspend fun getUser(@PathVariable id: String): User {
        // suspend function — Spring handles the coroutine lifecycle
        return userService.findById(id)
    }

    @GetMapping("/users")
    fun getUsers(): Flow<User> {
        // Flow — Spring streams responses
        return userService.findAll()
    }
}

Fix 7: Debug Coroutine Cancellation

Identify why coroutines are being cancelled:

// Add debug logging to coroutine job
val job = scope.launch {
    try {
        longOperation()
    } finally {
        // always runs — even on cancellation
        println("Coroutine finishing. isActive: $isActive, isCancelled: ${coroutineContext[Job]?.isCancelled}")
    }
}

job.invokeOnCompletion { throwable ->
    when (throwable) {
        null -> println("Completed normally")
        is CancellationException -> println("Cancelled: ${throwable.message}")
        else -> println("Failed: $throwable")
    }
}

// Check job state
println("isActive: ${job.isActive}")
println("isCompleted: ${job.isCompleted}")
println("isCancelled: ${job.isCancelled}")

Enable coroutine debug mode:

// build.gradle.kts — add coroutines debug library
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.7.3")
}
// In tests or development startup
DebugProbes.install()

// Dump all active coroutines
DebugProbes.dumpCoroutines()

// Get coroutine info
val coroutines = DebugProbes.dumpCoroutinesInfo()
coroutines.forEach { info ->
    println("${info.state}: ${info.context[CoroutineName]}")
    println(info.lastObservedStackTrace().take(5).joinToString("\n"))
}

Still Not Working?

GlobalScope — avoid itGlobalScope is not tied to any lifecycle. Coroutines launched in it run until the application exits or the job is cancelled manually. This is a common source of leaks. Always use a structured scope.

async exception handling — exceptions in async are stored in the Deferred and thrown when await() is called. If you never await(), the exception is silently lost (or propagates to the parent if the Deferred is garbage collected). Always await() results.

NonCancellable context — for cleanup code that must run even after cancellation, use withContext(NonCancellable):

launch {
    try {
        longOperation()
    } finally {
        withContext(NonCancellable) {
            // This runs even if the coroutine is cancelled
            database.cleanup()
            log.info("Cleaned up")
        }
    }
}

For related Kotlin issues, see Fix: Kotlin Coroutine Not Executing and Fix: Go Goroutine Leak.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles