Skip to content

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

FixDevs · (Updated: )

Part of:  Java & JVM Errors

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 model where every coroutine has a parent scope and every scope forms a tree of Job objects. Cancellation, failure, and completion flow through this tree according to strict rules. When a scope is cancelled, every coroutine launched in it receives a CancellationException at its next suspension point. When a child coroutine fails with a non-cancellation exception, the exception propagates up to the parent Job, which cancels itself and all other children. This bidirectional propagation — parent-to-children for cancellation, child-to-parent for failure — is the core mechanism.

The behavior differs significantly between Android and server-side Kotlin. On Android, viewModelScope is tied to the ViewModel lifecycle and cancels automatically when onCleared() is called. lifecycleScope is tied to the Activity or Fragment lifecycle. These scopes are designed to prevent leaks — a network request started by a destroyed Activity should not continue running. On the server side (Ktor, Spring WebFlux, raw coroutine code), there is no automatic lifecycle management. You create scopes manually, and forgetting to cancel them causes coroutine leaks. Conversely, cancelling a scope too early (or reusing a cancelled scope) causes silent failures where launch creates a coroutine that immediately cancels without executing any code.

The CancellationException class plays a special role. It is the only exception type that does not propagate from child to parent in structured concurrency. When a coroutine catches all Exception types (which includes CancellationException) and does not rethrow the CancellationException, it breaks the cancellation mechanism. The parent scope believes the child is still running, timers and resources are not released, and the application enters an inconsistent state.

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),
    )
}

SupervisorJob vs regular Job — when to use each:

ScenarioUse
Independent tasks (parallel API calls)SupervisorJob — one failure shouldn’t kill others
Dependent tasks (pipeline stages)Regular Job — failure in any stage invalidates the pipeline
UI scope (ViewModel, Activity)SupervisorJob — one failed button click shouldn’t crash the screen
Transaction-like batchRegular Job — all-or-nothing semantics

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: Android Lifecycle Scopes — viewModelScope vs lifecycleScope

Use the correct scope for each Android component. The choice determines when coroutines are cancelled:

// 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)

Common Android mistake — using GlobalScope for background work:

// WRONG — GlobalScope leaks, not tied to any lifecycle
class UserViewModel : ViewModel() {
    fun uploadFile(file: File) {
        GlobalScope.launch {
            // This runs even after the ViewModel is destroyed
            // If the user leaves the screen, this upload continues silently
            // Wasting battery, data, and potentially writing stale data
            api.upload(file)
        }
    }

    // CORRECT — use viewModelScope
    fun uploadFile(file: File) {
        viewModelScope.launch {
            api.upload(file)
            // Cancelled if ViewModel is cleared — no leak
        }
    }
}

Fix 5: Server-Side Coroutines — Ktor and Spring WebFlux

Server-side Kotlin has no automatic lifecycle management. You must create and cancel scopes explicitly:

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") {
        // Use application-level scope for background work
        application.launch {
            longRunningBackgroundJob()   // Not cancelled with request
        }
        call.respond("Job started")
    }
}

Ktor server lifecycle — cancel scopes on shutdown:

fun main() {
    val backgroundScope = CoroutineScope(
        SupervisorJob() + Dispatchers.Default + CoroutineName("background")
    )

    embeddedServer(Netty, port = 8080) {
        // Register shutdown hook
        environment.monitor.subscribe(ApplicationStopped) {
            backgroundScope.cancel()  // Cancel all background work on server stop
        }

        routing {
            get("/start-job") {
                backgroundScope.launch {
                    processQueue()
                }
                call.respond("started")
            }
        }
    }.start(wait = true)
}

Spring WebFlux — coroutine support in controllers:

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

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

    @GetMapping("/users")
    fun getUsers(): Flow<User> {
        // Flow — Spring streams responses
        // Each element is emitted as it becomes available
        return userService.findAll()
    }
}

// Spring WebFlux service layer
@Service
class UserService(private val userRepository: UserRepository) {

    // Spring creates a coroutine scope per request
    // CancellationException is thrown if the client disconnects
    suspend fun findById(id: String): User {
        return userRepository.findById(id)
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
    }

    // Flow-returning functions support backpressure
    fun findAll(): Flow<User> = flow {
        userRepository.findAll().forEach { user ->
            emit(user)  // Each emit checks for cancellation
        }
    }
}

Fix 6: 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 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")
        }
    }
}

Reusing a cancelled scope — once scope.cancel() is called, the scope’s Job moves to the Cancelled state permanently. New launch or async calls create coroutines that immediately cancel. Create a new CoroutineScope instead of reusing the cancelled one.

withTimeout vs withTimeoutOrNullwithTimeout throws TimeoutCancellationException (a subclass of CancellationException) when the timeout expires. If caught by a generic catch (e: Exception) block, it breaks cancellation. Use withTimeoutOrNull to return null on timeout instead of throwing:

val result = withTimeoutOrNull(5000) {
    fetchData()  // Returns null if takes more than 5 seconds
}
if (result == null) {
    // Handle timeout without throwing
}

Dispatchers.Main.immediate on Android — using Dispatchers.Main always dispatches through the message queue, adding a frame delay. Dispatchers.Main.immediate runs immediately if already on the main thread. This matters for cancellation timing — a cancellation signal dispatched through Dispatchers.Main may arrive one frame late.

For related Kotlin issues, see Fix: Kotlin Coroutine Not Executing, Fix: Kotlin Flow Not Working, Fix: Kotlin Sealed Class Not Working, and Fix: Swift Async/Await Not Working.

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