Fix: Kotlin Coroutine Scope Cancelled — JobCancellationException or Coroutine Not Running
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 children —
scope.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 parentJob, which cancels itself and all other children. CancellationExceptionis special — it’s used internally by the coroutine framework to implement cancellation. CatchingExceptionwithout rethrowingCancellationExceptionbreaks cancellation.- Cancelled scope can’t launch new coroutines — once a scope’s
Jobis in theCancelledstate,launchandasyncsilently create coroutines that immediately cancel. viewModelScopeis tied to ViewModel lifecycle — it cancels whenonCleared()is called (ViewModel is destroyed). Screen rotation in Android recreates the Activity but not the ViewModel —viewModelScopesurvives 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 it — GlobalScope 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Kotlin Flow Not Working — Not Collecting, StateFlow Not Updating, or Flow Cancelled Unexpectedly
How to fix Kotlin Flow issues — cold vs hot flows, collectLatest vs collect, StateFlow and SharedFlow setup, lifecycle-aware collection in Android, and common Flow cancellation problems.
Fix: Kotlin Coroutine Not Executing — launch{} or async{} Blocks Not Running
How to fix Kotlin coroutines not executing — CoroutineScope setup, dispatcher selection, structured concurrency, cancellation handling, blocking vs suspending calls, and exception propagation.
Fix: Kotlin Sealed Class Not Working — when Expression Not Exhaustive or Subclass Not Found
How to fix Kotlin sealed class issues — when exhaustiveness, sealed interface vs class, subclass visibility, Result pattern, and sealed classes across modules.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.