Coroutine Cancellation, SupervisorScope & Exception Handling
Advanced coroutine patterns that separate mid-level from senior Android developers
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Advanced coroutine handling covers the nuances that cause production bugs: cooperative cancellation (computing coroutines ignore cancellation without isActive checks), CancellationException propagation rules, SupervisorJob for independent failure isolation, CoroutineExceptionHandler for last-resort logging, and withContext(NonCancellable) for critical cleanup. These topics dominate senior Android interviews.
Real-world relevance
In a real-time SaaS collaboration app, a DocumentSyncScope uses SupervisorJob so that a failed comment sync doesn't cancel the document content sync. Long-running export operations use withTimeout. The SyncWorker uses isActive checks in its processing loop to respond to WorkManager cancellation. withContext(NonCancellable) ensures the local database transaction commits even if the user navigates away mid-sync.
Key points
- Cooperative cancellation — Coroutines must cooperate to be cancelled — they check for cancellation at suspension points (any suspend function call). A coroutine doing pure computation with no suspend calls will NOT be cancelled until it calls ensureActive() or yield() or hits a suspend point.
- isActive check — while (isActive) { processNextChunk() } — check isActive in computation-heavy loops. isActive is a property on CoroutineScope and Continuation. Returning false means the coroutine has been cancelled and should stop work.
- ensureActive() and yield() — ensureActive() throws CancellationException if the coroutine is cancelled (explicit check point). yield() suspends the coroutine briefly, allowing other coroutines to run AND checking for cancellation. Use yield() in tight loops processing large datasets.
- CancellationException is special — CancellationException is NOT treated as a failure by coroutine machinery — it's a normal termination signal. It does NOT cancel the parent scope. NEVER catch CancellationException without rethrowing it. catch (e: Exception) { } silently swallows it — a critical bug.
- try/finally in coroutines — try { suspendWork() } finally { cleanup() } — finally blocks run even when a coroutine is cancelled. Use finally for cleanup (closing connections, resetting state). Inside finally of a cancelled coroutine, you are in a cancellation state — avoid new suspend calls unless using NonCancellable.
- withContext(NonCancellable) — withContext(NonCancellable) { criticalCleanup() } runs the block even if the coroutine is cancelled. Use ONLY in finally blocks for critical cleanup that must complete (e.g., committing a database transaction, sending a telemetry event).
- Job vs SupervisorJob — Regular Job: if one child fails, all siblings are cancelled and the parent fails. SupervisorJob: if one child fails, siblings continue running. Use SupervisorJob in scopes where failures of individual operations should be independent (e.g., loading multiple sections of a screen independently).
- supervisorScope builder — supervisorScope { launch { task1() }; launch { task2() } } — if task1 fails, task2 continues. The scope itself fails only if the block itself throws. Use for parallel operations that should be independent (sidebar: vs coroutineScope where one failure cancels all).
- CoroutineExceptionHandler — val handler = CoroutineExceptionHandler { _, exception -> logError(exception) }; launch(handler) { } — handles uncaught exceptions from launch{} coroutines. Does NOT work with async{} (exceptions are stored in Deferred and thrown on await()). CoroutineExceptionHandler is a last-resort handler, not a try/catch replacement.
- Exception propagation rules — launch{}: exceptions propagate to parent immediately, cancelling siblings. async{}: exceptions are stored in Deferred — thrown when you call await(). So wrap await() calls in try/catch, not the async{} block itself.
- Cancellation and resource cleanup — When a coroutine is cancelled mid-operation (e.g., user navigates away during API call), resources must be cleaned up. Retrofit/OkHttp calls are cancellable — the HTTP request is aborted. Room queries are cancellable. Custom code must check isActive in loops.
- timeout and withTimeout — withTimeout(5000) { longRunningOperation() } cancels the coroutine and throws TimeoutCancellationException if it doesn't complete within 5 seconds. withTimeoutOrNull returns null on timeout instead of throwing. Use for network requests with strict SLAs.
Code example
// Cooperative cancellation in a processing loop
suspend fun processLargeDataset(items: List<Item>) {
items.forEach { item ->
ensureActive() // throw CancellationException if cancelled
processItem(item)
}
}
// try/finally for cleanup — runs even on cancellation
suspend fun syncWithCleanup(docId: String) {
val lock = acquireLock(docId)
try {
performSync(docId)
} finally {
// Runs even if coroutine is cancelled
withContext(NonCancellable) {
lock.release() // suspend call safe in NonCancellable
logSyncAttempt(docId) // telemetry must complete
}
}
}
// SupervisorJob — independent failure isolation
class DocumentViewModel : ViewModel() {
// viewModelScope uses SupervisorJob internally — good!
fun loadDocumentSections(docId: String) {
viewModelScope.launch {
supervisorScope {
launch {
val content = contentRepo.load(docId)
_contentState.value = UiState.Success(content)
}
launch {
val comments = commentsRepo.load(docId) // failure here
_commentsState.value = UiState.Success(comments) // won't cancel content
}
}
}
}
}
// CoroutineExceptionHandler — last resort
val exceptionHandler = CoroutineExceptionHandler { context, throwable ->
if (throwable !is CancellationException) {
Crashlytics.recordException(throwable)
_errorEvent.tryEmit(throwable.message ?: "Unknown error")
}
}
// Exception propagation: async vs launch
fun demonstrateExceptionDifference() {
viewModelScope.launch(exceptionHandler) {
// async — exception stored in Deferred, thrown on await()
val result = async { riskyOperation() }
try {
val value = result.await() // exception thrown HERE
} catch (e: Exception) {
handleError(e)
}
}
}
// withTimeout for SLA-bound operations
suspend fun fetchWithTimeout(id: String): Document? =
withTimeoutOrNull(5_000) {
api.fetchDocument(id) // returns null if > 5 seconds
}Line-by-line walkthrough
- 1. ensureActive() inside forEach — throws CancellationException immediately if cancelled; converts non-cancellable loop into cancellable
- 2. val lock = acquireLock(docId) before try — ensures lock is captured even if acquisition itself can't be undone
- 3. withContext(NonCancellable) { lock.release() } in finally — NonCancellable context allows suspend calls after cancellation
- 4. supervisorScope { launch { content }; launch { comments } } — two independent children; comments failure doesn't cancel content
- 5. CoroutineExceptionHandler { _, throwable -> } — receives unhandled exceptions from launch{}; last resort for logging
- 6. if (throwable !is CancellationException) Crashlytics.recordException — never report cancellations as crashes; they're normal
- 7. val result = async { riskyOperation() } — exception stored in Deferred until await()
- 8. result.await() inside try/catch — this is where async exception is thrown; CORRECT place to catch it
- 9. withTimeoutOrNull(5_000) { } — returns null on timeout instead of throwing; safe for optional operations
Spot the bug
fun loadData() {
viewModelScope.launch {
try {
val result = async { fetchFromNetwork() }
processResult(result.await())
} catch (e: CancellationException) {
// "safe" to ignore — it's just cancellation
showError("Cancelled")
} catch (e: Exception) {
showError(e.message)
}
}
}
suspend fun longProcess(items: List<Item>) {
for (item in items) {
heavyProcessing(item) // CPU work, no suspend calls
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Coroutine Cancellation and Timeouts (kotlinlang.org)
- Exception Handling in Coroutines (kotlinlang.org)
- Coroutines and Structured Concurrency (kotlinlang.org)
- SupervisorJob and Supervision (kotlinlang.org)