Cancellation is one of the most important — and most misunderstood — parts of Kotlin coroutines. Done right, it prevents memory leaks, stops unnecessary work, and keeps your app responsive. Done wrong, coroutines run forever after they're no longer needed, or they ignore cancellation completely. This guide covers how cancellation works, how to make your code cancellation-safe, and the common pitfalls to avoid.
How Cancellation Works
When you cancel a coroutine, it doesn't stop immediately. Instead, Kotlin sets a cancellation flag on the coroutine's Job. The coroutine checks this flag at suspension points — places where it calls a suspending function. When a cancellation is detected at a suspension point, a CancellationException is thrown.
val job = viewModelScope.launch {
println("Started")
delay(1000) // suspension point — cancellation checked here
println("After delay") // never runs if cancelled during delay
}
job.cancel() // sets cancellation flag
// CancellationException thrown at delay() — coroutine stops there
This means cancellation is cooperative — a coroutine must reach a suspension point to be cancelled. Code running between suspension points cannot be interrupted.
Cancelling Jobs
// cancel() — requests cancellation
val job = launch { doWork() }
job.cancel() // request cancellation
// cancel() with message
job.cancel(CancellationException("User navigated away"))
// cancelAndJoin() — cancel and wait for completion
job.cancelAndJoin() // suspends until job is fully done
// cancel a scope — cancels all children
viewModelScope.cancel() // cancels all coroutines in the scope
// Check if job is active
println(job.isActive) // true if running
println(job.isCancelled) // true if cancelled
println(job.isCompleted) // true if finished (normally or by cancellation)
Cancelling a specific operation in ViewModel
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private var searchJob: Job? = null
fun search(query: String) {
// Cancel previous search before starting a new one
searchJob?.cancel()
searchJob = viewModelScope.launch {
delay(300) // debounce — wait for user to stop typing
val results = repository.search(query)
_results.value = results
}
}
// Automatically called when ViewModel is destroyed
// viewModelScope.cancel() called internally — all jobs cancelled
}
Cooperative Cancellation — Making Code Cancellable
Standard suspending functions from the Kotlin coroutines library (delay, withContext, network calls, etc.) are all cancellation-aware. But CPU-heavy work that doesn't suspend is not cancellable by default:
val job = viewModelScope.launch {
// ❌ Heavy computation with no suspension points — not cancellable
var result = 0L
for (i in 1..1_000_000_000L) {
result += i // runs forever even after job.cancel()
}
println("Sum: $result")
}
job.cancel()
// job.isCancelled = true, but the loop still runs — no suspension points to check
Option 1: Check isActive periodically
val job = viewModelScope.launch {
var result = 0L
for (i in 1..1_000_000_000L) {
if (!isActive) break // ✅ check cancellation flag manually
result += i
}
println("Sum: $result")
}
job.cancel()
// Loop exits at the next isActive check
Option 2: Use ensureActive()
val job = viewModelScope.launch {
var result = 0L
for (i in 1..1_000_000_000L) {
ensureActive() // ✅ throws CancellationException if cancelled
result += i
}
}
// ensureActive() is cleaner — throws instead of breaking,
// letting finally blocks and cleanup run properly
Option 3: yield() — suspend and check cancellation
val job = viewModelScope.launch {
for (chunk in largeDataSet.chunked(1000)) {
yield() // ✅ suspends briefly, checks cancellation, then resumes
processChunk(chunk)
}
}
// yield() also gives other coroutines a chance to run — good for fairness
Cleanup with finally
When a coroutine is cancelled, CancellationException is thrown at the suspension point. This means finally blocks always run — perfect for cleanup:
val job = viewModelScope.launch {
try {
_isLoading.value = true
val articles = repository.getArticles() // suspend — cancellable
_articles.value = articles
} catch (e: CancellationException) {
println("Coroutine was cancelled")
throw e // re-throw — important!
} finally {
_isLoading.value = false // always runs — even on cancellation
println("Cleanup done")
}
}
job.cancel()
// finally block runs — isLoading set to false
// CancellationException re-thrown — propagates correctly
Running non-cancellable code in finally
Sometimes you need to run suspending code during cleanup — but the coroutine is already cancelled, so suspension points would throw immediately. Use withContext(NonCancellable):
val job = viewModelScope.launch {
try {
val session = database.openSession()
processData(session)
} finally {
// Need to close session — but coroutine is cancelled
// Without NonCancellable, the suspend call below would throw immediately
withContext(NonCancellable) {
database.closeSession() // ✅ runs even though coroutine is cancelled
analyticsService.logSessionEnd() // ✅ also runs
}
}
}
Cancellation and Structured Concurrency
Cancellation flows through the job hierarchy — cancelling a parent cancels all its children:
val parentJob = viewModelScope.launch {
val child1 = launch {
delay(1000)
println("Child 1 done") // never runs
}
val child2 = launch {
delay(2000)
println("Child 2 done") // never runs
}
println("Parent waiting")
}
parentJob.cancel()
// Both child1 and child2 are cancelled immediately
Cancelling a child doesn't cancel the parent
viewModelScope.launch {
val child = launch {
delay(1000)
println("Child done")
}
delay(500)
child.cancel() // only child is cancelled — parent continues
println("Parent still running") // ✅ this runs
}
withTimeout and withTimeoutOrNull
Automatically cancel a coroutine if it takes too long:
// withTimeout — throws TimeoutCancellationException if exceeded
try {
val result = withTimeout(5000L) { // 5 second limit
repository.getArticles() // if this takes more than 5s, throws
}
showArticles(result)
} catch (e: TimeoutCancellationException) {
showError("Request timed out")
}
// withTimeoutOrNull — returns null on timeout, no exception
val result = withTimeoutOrNull(5000L) {
repository.getArticles()
}
if (result == null) {
showError("Request timed out")
} else {
showArticles(result)
}
Timeout in ViewModel with safeApiCall
suspend fun <T> safeApiCallWithTimeout(
timeoutMs: Long = 10_000L,
call: suspend () -> T
): Result<T> {
return try {
val data = withTimeout(timeoutMs) { call() }
Result.Success(data)
} catch (e: TimeoutCancellationException) {
Result.Error(e, "Request timed out after ${timeoutMs / 1000}s")
} catch (e: CancellationException) {
throw e // re-throw real cancellation
} catch (e: Exception) {
Result.Error(e, e.message ?: "Unknown error")
}
}
Real Android Patterns
Cancel and restart on new input — search debounce
class SearchViewModel(private val repository: SearchRepository) : ViewModel() {
private val _results = MutableStateFlow<List<SearchResult>>(emptyList())
val results: StateFlow<List<SearchResult>> = _results
private var activeSearch: Job? = null
fun onQueryChanged(query: String) {
activeSearch?.cancel() // cancel previous search
if (query.isBlank()) {
_results.value = emptyList()
return
}
activeSearch = viewModelScope.launch {
delay(300) // debounce — if cancelled before 300ms, no search happens
try {
_results.value = repository.search(query)
} catch (e: CancellationException) {
throw e // re-throw — this is expected behaviour
} catch (e: Exception) {
_results.value = emptyList()
}
}
}
}
Lifecycle-aware cancellation in Fragment
class ArticleFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// viewLifecycleOwner.lifecycleScope is automatically cancelled
// when the view is destroyed — no manual cancellation needed
viewLifecycleOwner.lifecycleScope.launch {
viewModel.articles.collect { articles ->
adapter.submitList(articles)
}
}
// repeatOnLifecycle — automatically pauses/resumes with lifecycle
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
renderUiState(state)
}
}
}
}
// When fragment view is destroyed → lifecycleScope cancelled → collection stopped
}
Cancelling a download operation
class DownloadViewModel(private val downloader: Downloader) : ViewModel() {
private val _progress = MutableStateFlow(0)
val progress: StateFlow<Int> = _progress
private var downloadJob: Job? = null
fun startDownload(url: String) {
downloadJob = viewModelScope.launch {
try {
downloader.download(url) { progress ->
ensureActive() // check cancellation between progress updates
_progress.value = progress
}
_progress.value = 100
} finally {
// cleanup runs whether completed, cancelled, or error
downloader.cleanup()
}
}
}
fun cancelDownload() {
downloadJob?.cancel()
_progress.value = 0
}
}
Common Mistakes to Avoid
Mistake 1: CPU-heavy work with no cancellation checks
// ❌ Not cancellable — runs forever even after cancel()
launch {
val result = heavyComputation() // no suspension points
}
// ✅ Add ensureActive() or isActive checks
launch {
val result = withContext(Dispatchers.Default) {
var sum = 0L
for (i in 1..1_000_000L) {
ensureActive()
sum += i
}
sum
}
}
Mistake 2: Swallowing CancellationException
// ❌ Swallows CancellationException — coroutine thinks it's still running
launch {
try {
doWork()
} catch (e: Exception) {
log(e) // catches CancellationException and ignores it!
}
}
// ✅ Always re-throw CancellationException
launch {
try {
doWork()
} catch (e: CancellationException) {
throw e // re-throw
} catch (e: Exception) {
log(e)
}
}
Mistake 3: Using suspend in finally without NonCancellable
// ❌ Suspension in finally will immediately throw — coroutine already cancelled
launch {
try {
doWork()
} finally {
delay(100) // ❌ throws CancellationException immediately
closeConnection() // never reached
}
}
// ✅ Use NonCancellable for suspending cleanup
launch {
try {
doWork()
} finally {
withContext(NonCancellable) {
delay(100) // ✅ runs safely
closeConnection() // ✅ runs safely
}
}
}
Mistake 4: Cancelling the wrong scope
// ❌ Cancelling viewModelScope — kills ALL coroutines permanently
// viewModelScope cannot be restarted after cancel()
viewModelScope.cancel() // only do this if you want to stop everything forever
// ✅ Cancel individual jobs — scope remains active for new coroutines
searchJob?.cancel() // only this job is cancelled
loadJob?.cancel() // only this job is cancelled
Summary
- Cancellation is cooperative — coroutines check for cancellation at suspension points, not mid-computation
CancellationExceptionis thrown at the next suspension point when a coroutine is cancelled- Always re-throw
CancellationException— swallowing it breaks structured cancellation - Make CPU-heavy loops cancellable with
isActive,ensureActive(), oryield() finallyblocks always run on cancellation — use them for cleanup- Use
withContext(NonCancellable)to run suspending code infinallysafely withTimeoutthrowsTimeoutCancellationException;withTimeoutOrNullreturnsnull- Cancelling a parent cancels all children; cancelling a child does not affect the parent
viewModelScopeis automatically cancelled when the ViewModel is destroyed — no manual cleanup neededviewLifecycleOwner.lifecycleScopeis cancelled when the view is destroyed- Cancel and restart pattern (search debounce) —
job?.cancel()then re-launch - Never cancel
viewModelScopedirectly — cancel individual jobs instead
Cancellation is the mechanism that makes structured concurrency work. Once you understand that it's cooperative — driven by suspension points and CancellationException — everything else clicks into place. Combine proper cancellation with lifecycle-aware scopes and you'll never again wonder why a coroutine is still running after it should have stopped.
Happy coding!
Comments (0)