Sitemap

Kotlin Coroutine Cancellation: An Advanced Guide

5 min readMay 7, 2025

Coroutines make Android development safer and more efficient — but only if you handle cancellation correctly. This guide covers everything from the basics to real-world scenarios and best practices for writing robust, lifecycle-aware coroutine code.

🔁 1. Cancellation in Kotlin Coroutines: The Basics

Coroutine cancellation is cooperative, not forceful:

  • Coroutines only cancel when they reach a suspension point or check isActive/ensureActive().
  • Cancellation won’t interrupt a CPU-bound loop unless you tell it to.

Example: Ignored Cancellation

val job = CoroutineScope(Dispatchers.Default).launch {
repeat(1_000_000) { i ->
println("Working on $i")
}
}
Thread.sleep(100)
job.cancel() // This won't stop the coroutinek

Fix It with isActive

val job = CoroutineScope(Dispatchers.Default).launch {
repeat(1_000_000) { i ->
if (!isActive) return@launch
println("Working on $i")
}
}
job.cancel()

🔐 Even Safer with ensureActive()

val job = CoroutineScope(Dispatchers.Default).launch {
repeat(1_000_000) { i ->
ensureActive()
println("Working on $i")
}
}
job.cancel()

⏸️ Suspending Functions Respect Cancellation

val job = CoroutineScope(Dispatchers.Default).launch {
repeat(1000) { i ->
println("Working on $i")
delay(100)
}
}
job.cancel()

🧨 2. Cancellation Pitfalls and Edge Cases

Even well-written coroutines might behave unexpectedly if these edge cases are not handled:

⚠️ CPU-Bound Code Without Suspension

Coroutines doing heavy computation without checking isActive will ignore cancellation:

val job = CoroutineScope(Dispatchers.Default).launch {
for (i in 1..Int.MAX_VALUE) {
// No suspension, no check — this won’t cancel
println(i)
}
}
job.cancel()

Fix: Insert yield() or ensureActive() manually.

⚠️ CancellationException Swallowed in catch

val job = CoroutineScope(Dispatchers.Default).launch {
try {
delay(1000)
} catch (e: Exception) {
println("Caught: ${e.message}") // Bad idea — this swallows CancellationException
}
}
job.cancel()

Fix: Catch CancellationException separately and rethrow it:

catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// Handle other exceptions
}

🔄 3. Cancellation Behavior Across Scopes

If you want to know more about Coroutine Context and Scopes, Read here.

supervisorScope Example (Safe and Clear)

fun supervisorScopeExample() {
CoroutineScope(Dispatchers.Default).launch {
supervisorScope {
val job1 = launch {
println("Job 1: Starting")
delay(100)
println("Job 1: Throwing exception")
throw RuntimeException("Job 1 failed") // No try-catch here
}

val job2 = launch {
println("Job 2: Starting")
delay(500)
println("Job 2: Finished")
}
}
}
}

Cancel a Child without Cancelling the Parent

fun childCancelledWithoutParent() {
CoroutineScope(Dispatchers.Default).launch {
val child = launch {
try {
repeat(10) {
println("Child working $it")
delay(100)
}
} finally {
println("Child cancelled")
}
}

delay(250)
child.cancel()
println("Parent still active")
}
}

Cancel the Parent (and All Children)

fun cancelParentAndChildren() {
val scope = CoroutineScope(Dispatchers.Default)

val parentJob = scope.launch {
launch {
repeat(10) {
println("Child working $it")
delay(100)
}
}
}

scope.launch {
delay(300)
parentJob.cancel()
println("Parent cancelled")
}
}

🧠 cancelChildren() vs cancel()

fun cancelChildrenThenParent() {
val scope = CoroutineScope(Dispatchers.Default)

scope.launch {
launch { delay(500); println("Child 1 done") }
launch { delay(1000); println("Child 2 done") }

delay(200)
coroutineContext.cancelChildren() // Cancels children only
println("Parent still running")
}

scope.launch {
delay(1500)
coroutineContext.cancel() // Cancels parent and children
println("Scope and parent cancelled")
}
}

🧩 4. Real-World Compose Example: Manual Cancellation + Cleanup

@Composable
fun FileSyncScreen() {
val viewModel: FileSyncViewModel = viewModel()
val uiState by viewModel.uiState.collectAsState()

LaunchedEffect(Unit) {
viewModel.startSync()
}

Column(modifier = Modifier.padding(16.dp)) {
Text("Status: ${uiState.status}")

Spacer(Modifier.height(16.dp))

Row {
Button(onClick = { viewModel.startSync() }) {
Text("Start Sync")
}

Spacer(modifier = Modifier.width(8.dp))

Button(onClick = { viewModel.cancelSync() }) {
Text("Cancel Sync")
}
}
}
}

class FileSyncViewModel : ViewModel() {
private var syncJob: Job? = null
private val _uiState = MutableStateFlow(SyncUiState("Idle"))
val uiState: StateFlow<SyncUiState> = _uiState

fun startSync() {
if (syncJob?.isActive == true) return
syncJob = viewModelScope.launch {
try {
for (i in 1..1000) {
ensureActive()
delay(100)
_uiState.value = SyncUiState("Syncing file $i")
}
_uiState.value = SyncUiState("Complete")
} catch (e: CancellationException) {
_uiState.value = SyncUiState("Cancelled")
} finally {
withContext(NonCancellable) {
println("Shutting down sync module...")
delay(300)
println("Cleanup finished")
}
}
}
}

fun cancelSync() {
syncJob?.cancel()
}
}

data class SyncUiState(val status: String)

🧼 5. NonCancellable: Guaranteed Cleanup

Some operations must run even when the coroutine is cancelled — like closing sockets, releasing resources, or saving user progress. That’s what NonCancellable is for.

✅ Use Inside finally:

launch {
try {
doWork()
} finally {
withContext(NonCancellable) {
delay(300) // Still runs even if cancelled
println("Cleanup complete")
}
}
}

❗ Use Sparingly

NonCancellable bypasses cooperative cancellation — misuse can delay user feedback or app shutdown.

✅ Use for:

  • Closing sockets, releasing resources
  • Logging, analytics, or saving state

🚫 Avoid for:

  • Long-running operations (e.g., sync tasks)
  • UI updates or user-facing logic
  • Forcing tasks to “complete anyway

🪤 6. Making computation code cancellable

isActive vs ensureActive() vs yield()

  • Use isActive when you need a lightweight boolean check.
  • Use ensureActive() for early termination with exception bubbling.
  • Use yield() to both check cancellation and allow fair scheduling

🛠 Best Practices Recap

  • ✅ Use isActive, ensureActive(), or yield() in long-running loops.
  • ✅ Clean up in finally with NonCancellable where needed.
  • ✅ Avoid GlobalScope — prefer viewModelScope or lifecycleScope.
  • ✅ Use supervisorScope to isolate sibling coroutine failures.
  • ✅ Don’t swallow CancellationException — always rethrow.
  • ✅ Track and cancel custom jobs explicitly in Compose.

📌 Final Thoughts

Mastering coroutine cancellation isn’t just about stopping tasks — it’s about writing resilient, cooperative, and testable concurrent code. With lifecycle-aware scopes, correct cancellation patterns, and conscious use of NonCancellable, your code will be easier to debug, maintain, and scale.

If you enjoyed this post, don’t forget to clap and show your support!

Make sure to follow me for the latest insights on software engineering trends.

For more about my work, including links to my LinkedIn, GitHub, and other projects, feel free to visit my portfolio: https://omardroid.github.io/portfolio/

Let’s connect and explore opportunities in mobile software engineering!

--

--

Responses (1)