You know how to launch a coroutine and call suspend functions. But what happens when a coroutine fails? What controls which thread it runs on? How do you cancel one coroutine without killing all the others? The answers live inside CoroutineContext — the configuration system that powers every coroutine. This guide covers the context system, scoping rules, SupervisorJob, error handling, and the utilities that make production coroutine code robust.
The Mental Model — What Is CoroutineContext?
// Think of CoroutineContext as a PASSPORT for your coroutine:
//
// ┌──────────────────────────────────────┐
// │ COROUTINE PASSPORT │
// │ │
// │ Name: article-loader │ ← CoroutineName
// │ Thread: IO pool │ ← Dispatcher
// │ Parent: viewModelScope │ ← Job
// │ Error handler: log to analytics │ ← ExceptionHandler
// └──────────────────────────────────────┘
//
// Every coroutine carries this passport
// It determines: WHERE it runs, WHO its parent is,
// WHAT its name is, and WHAT happens when it fails
//
// The passport is built from ELEMENTS — each element handles one concern
// Elements are combined with the + operator
CoroutineContext — Elements, Keys, and the + Operator
// CoroutineContext is an INTERFACE from kotlin.coroutines
// It behaves like an IMMUTABLE MAP of elements
// Each element has a unique KEY:
// CoroutineContext.Element is an INTERFACE — one piece of configuration
// CoroutineContext.Key is an INTERFACE — identifies the element type
// The four most common elements:
// 1. Job — controls the coroutine's lifecycle (parent/child, cancellation)
val job = Job()
// Job() is a TOP-LEVEL FUNCTION that returns a CompletableJob
// 2. Dispatcher — controls WHICH THREAD runs the code
val dispatcher = Dispatchers.IO
// Dispatchers is an OBJECT with properties: Main, IO, Default, Unconfined
// 3. CoroutineName — a name for debugging
val name = CoroutineName("article-loader")
// CoroutineName is a CLASS implementing CoroutineContext.Element
// Shows up in thread names: "article-loader #coroutine#1"
// 4. CoroutineExceptionHandler — catches unhandled exceptions
val handler = CoroutineExceptionHandler { _, throwable ->
println("Caught: ${throwable.message}")
}
// CoroutineExceptionHandler is an INTERFACE extending CoroutineContext.Element
// COMBINING elements with the + operator:
val context = Job() + Dispatchers.IO + CoroutineName("loader") + handler
// + is an OPERATOR FUNCTION on CoroutineContext
// Creates a NEW context containing all four elements
// If both sides have the same Key → RIGHT side wins (replaces)
val context1 = Dispatchers.IO + CoroutineName("A")
val context2 = context1 + Dispatchers.Main
// context2 has: Dispatchers.Main (replaced IO) + CoroutineName("A") (kept)
// ACCESSING elements by Key:
val ctx = Dispatchers.IO + CoroutineName("loader")
val dispatcher = ctx[CoroutineDispatcher]
// [] is the GET OPERATOR on CoroutineContext — looks up by Key
// Returns CoroutineDispatcher? (nullable — might not exist)
val name = ctx[CoroutineName]?.name // "loader"
val job = ctx[Job] // null (we didn't add one)
// Inside a coroutine, access the current context:
launch {
val myName = coroutineContext[CoroutineName]?.name
// coroutineContext is a PROPERTY available inside any coroutine
// It's the FULL context of the current coroutine
val myJob = coroutineContext[Job] // the current coroutine's Job
}
How context inheritance works
// When you launch a child coroutine, it INHERITS the parent's context
// Then your additions OVERRIDE specific elements
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + CoroutineName("parent"))
scope.launch {
// This coroutine inherits: SupervisorJob(child) + Main + "parent"
println(coroutineContext[CoroutineName]?.name) // "parent"
launch(Dispatchers.IO + CoroutineName("child")) {
// This coroutine has: Job(child-of-child) + IO + "child"
// IO replaced Main, "child" replaced "parent"
// But the Job is still a CHILD of the parent's Job
println(coroutineContext[CoroutineName]?.name) // "child"
}
}
// Inheritance formula:
// child context = parent context + child's additional elements
// Job is SPECIAL — child always gets a NEW Job that is a CHILD of parent's Job
// Even if you pass Job() explicitly, Hilt/scope creates a child Job
Scopes — Lifecycle Boundaries
// A CoroutineScope holds a CoroutineContext and defines the LIFETIME
// When the scope is cancelled → ALL coroutines inside it are cancelled
// CoroutineScope is an INTERFACE from kotlinx.coroutines
// ═══ viewModelScope ═════════════════════════════════════════════════
viewModelScope.launch { loadArticles() }
// viewModelScope is an EXTENSION PROPERTY on ViewModel
// Created with: SupervisorJob() + Dispatchers.Main.immediate
// Cancelled automatically in ViewModel.onCleared()
// User leaves screen → ViewModel cleared → all coroutines cancelled
// ═══ lifecycleScope ═════════════════════════════════════════════════
lifecycleScope.launch { collectFlow() }
// lifecycleScope is an EXTENSION PROPERTY on LifecycleOwner
// Cancelled when lifecycle reaches DESTROYED
// ═══ GlobalScope — the DANGEROUS one ════════════════════════════════
GlobalScope.launch { uploadData() }
// GlobalScope is an OBJECT implementing CoroutineScope
//
// WHY IT'S DANGEROUS:
// 1. NEVER cancelled — lives until the process dies
// User navigates away → coroutine keeps running → memory leak!
// Activity destroyed → coroutine holds references → leaked Activity!
//
// 2. No STRUCTURED CONCURRENCY
// Parent scope can't cancel GlobalScope children
// You lose track of what's running
//
// 3. EXCEPTION propagation broken
// No parent Job to notify → exceptions may crash silently or be swallowed
//
// 4. HARD TO TEST
// No way to control or wait for GlobalScope from tests
//
// WHEN TO USE GlobalScope (almost never):
// ✅ Truly app-global, fire-and-forget work that should outlive ANY screen
// Example: sending analytics batch on app exit
// Even then, WorkManager is usually better
//
// INSTEAD OF GlobalScope:
// viewModelScope → for screen-level work
// lifecycleScope → for Activity/Fragment work
// Custom scope → for component-level work (your own cancel() method)
// ═══ Custom Scope ════════════════════════════════════════════════════
class ImageDownloader {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// SupervisorJob() is a TOP-LEVEL FUNCTION → returns CompletableJob
// Children can fail independently
fun downloadAll(urls: List<String>) {
urls.forEach { url ->
scope.launch { download(url) }
}
}
fun cancel() {
scope.cancel()
// cancel() is an EXTENSION FUNCTION on CoroutineScope
// Cancels ALL coroutines launched in this scope
}
}
// Call cancel() when the component is done — clean, predictable lifecycle
Job and Structured Concurrency
// Every coroutine has a JOB — represents its lifecycle
// Job is an INTERFACE from kotlinx.coroutines
val job = viewModelScope.launch {
fetchAndDisplayArticles()
}
// Job properties:
job.isActive // true while running
job.isCancelled // true if cancelled
job.isCompleted // true if finished (success, failure, or cancellation)
// Job functions:
job.cancel() // request cancellation (cooperative)
// cancel() is a FUNCTION on Job
job.join() // suspend until the job completes
// join() is a SUSPEND FUNCTION on Job
job.cancelAndJoin() // cancel + wait for completion
// cancelAndJoin() is an EXTENSION SUSPEND FUNCTION on Job
// ═══ PARENT-CHILD RULES (Structured Concurrency) ═════════════════════
//
// viewModelScope.launch { ← parent Job A
// launch { task1() } ← child Job B (child of A)
// launch { task2() } ← child Job C (child of A)
// }
//
// Rule 1: Parent WAITS for all children before completing
// A doesn't complete until B and C complete
//
// Rule 2: Parent cancelled → all children cancelled
// cancel A → B and C are cancelled too
//
// Rule 3: Child FAILURE → parent cancelled → siblings cancelled
// B throws → A is notified → A cancels C → A fails
//
// This is WHY structured concurrency is safe:
// No orphaned coroutines, no fire-and-forget leaks
// Cancel the scope → everything inside stops
SupervisorJob — When Children Should Fail Independently
// PROBLEM with normal Job:
// One failed API call cancels EVERYTHING — even unrelated work!
viewModelScope.launch {
launch { loadArticles() } // throws IOException!
launch { loadWeather() } // gets CANCELLED because sibling failed!
launch { loadNotifications() } // gets CANCELLED because sibling failed!
}
// All three are children of the same parent Job
// One fails → parent cancels → siblings cancel → user sees nothing!
// SOLUTION: SupervisorJob — child failure doesn't propagate UP
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch { loadArticles() } // throws → fails alone
scope.launch { loadWeather() } // KEEPS RUNNING ✅
scope.launch { loadNotifications() } // KEEPS RUNNING ✅
// SupervisorJob() is a TOP-LEVEL FUNCTION → returns CompletableJob
// Difference from Job():
// Job(): child failure → cancels parent → cancels all siblings
// SupervisorJob(): child failure → IGNORED by parent → siblings unaffected
// viewModelScope ALREADY uses SupervisorJob!
// That's why in a ViewModel, one failed launch doesn't kill other launches
// viewModelScope = SupervisorJob() + Dispatchers.Main.immediate
supervisorScope — supervisor inside a suspend function
// Sometimes you need supervisor behavior INSIDE a coroutine, not at the scope level
suspend fun loadDashboard(): DashboardData {
return supervisorScope {
// supervisorScope is a TOP-LEVEL SUSPEND FUNCTION
// Creates a supervisor scope — children fail independently
// Waits for ALL children before returning
val user = async {
try { repository.getUser() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { null } // failed → return null
}
val articles = async {
try { repository.getArticles() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { emptyList() } // failed → return empty
}
val notifications = async {
try { repository.getNotifications() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { emptyList() }
}
// Even if getUser() fails, articles and notifications continue!
DashboardData(
user = user.await(),
articles = articles.await(),
notifications = notifications.await()
)
}
}
// coroutineScope vs supervisorScope:
//
// coroutineScope {
// async { A() } // if A fails → B is CANCELLED
// async { B() }
// }
// Use when: ALL tasks must succeed (you need A AND B)
//
// supervisorScope {
// async { A() } // if A fails → B keeps running
// async { B() }
// }
// Use when: tasks are INDEPENDENT (nice to have A, nice to have B)
CoroutineExceptionHandler — The Safety Net
// When a coroutine throws and nobody catches it, the app CRASHES
// CoroutineExceptionHandler is a LAST RESORT safety net
val handler = CoroutineExceptionHandler { context, throwable ->
// CoroutineExceptionHandler is an INTERFACE extending CoroutineContext.Element
// context — the coroutine's context (you can read CoroutineName etc.)
// throwable — the unhandled exception
val name = context[CoroutineName]?.name ?: "unnamed"
println("Coroutine '$name' failed: ${throwable.message}")
analytics.logCrash(throwable)
// Don't throw here — it won't propagate, just log/report
}
// Install it on the scope or root coroutine:
viewModelScope.launch(handler) {
// If this throws and nobody catches → handler is called
riskyOperation()
}
// ⚠️ CRITICAL RULES about CoroutineExceptionHandler:
//
// Rule 1: Works with LAUNCH only, NOT with ASYNC
viewModelScope.launch(handler) {
throw IOException("boom") // ✅ handler catches this
}
val deferred = viewModelScope.async(handler) {
throw IOException("boom") // ❌ handler is NOT called!
}
deferred.await() // exception is thrown HERE — must try-catch around await()
// Rule 2: Must be on the ROOT coroutine or the SCOPE
viewModelScope.launch(handler) { // ✅ root — handler works
launch { // child inherits handler
throw Exception("inner") // ✅ handler catches this too
}
}
viewModelScope.launch {
launch(handler) { // ❌ handler on CHILD — ignored!
throw Exception("inner") // handler NOT called — exception goes to parent
}
}
// Rule 3: CancellationException is NEVER caught by the handler
// Cancellation is normal — not an error — handler ignores it
// BEST PRACTICE: use try-catch for expected errors, handler for unexpected ones
viewModelScope.launch(handler) {
try {
val data = api.getData()
_uiState.value = UiState.Success(data)
} catch (e: CancellationException) { throw e }
catch (e: HttpException) {
// Expected error — handle gracefully
_uiState.value = UiState.Error(e.message())
}
// Unexpected errors (NPE, ClassCast) → fall through to handler
}
Cancellation Utilities — ensureActive, yield, NonCancellable
Why cancellation is cooperative
// Calling job.cancel() doesn't KILL the coroutine immediately
// It REQUESTS cancellation — the coroutine must CHECK and comply
//
// Cancellation happens at SUSPENSION POINTS:
// delay(), withContext(), yield(), network calls, database queries
// At each suspension, the coroutine checks: "Am I cancelled?"
// If yes → throws CancellationException → coroutine stops
//
// PROBLEM: CPU-heavy loops have NO suspension points!
viewModelScope.launch {
var total = 0
for (i in 1..10_000_000) {
total += expensiveCalculation(i)
}
// This loop NEVER suspends — cancel() has NO EFFECT
// The coroutine ignores cancellation until the loop finishes!
}
ensureActive — check cancellation explicitly
viewModelScope.launch {
var total = 0
for (i in 1..10_000_000) {
ensureActive()
// ensureActive() is an EXTENSION FUNCTION on CoroutineScope
// Checks: is this coroutine still active?
// If cancelled → throws CancellationException immediately
// If active → does nothing (no suspension, very fast)
total += expensiveCalculation(i)
}
}
// Now cancel() works — ensureActive() checks on every iteration
// Cost: negligible — just reads a boolean flag
yield — check cancellation AND let others run
viewModelScope.launch {
for (i in 1..10_000_000) {
if (i % 1000 == 0) yield()
// yield() is a TOP-LEVEL SUSPEND FUNCTION
// Does TWO things:
// 1. Checks for cancellation (like ensureActive)
// 2. SUSPENDS briefly — gives other coroutines a chance to run
//
// More expensive than ensureActive (actually suspends)
// Use when fairness matters — don't hog the thread
compute(i)
}
}
// ensureActive vs yield:
// ensureActive() → just checks cancellation, no suspension (faster)
// yield() → checks cancellation AND suspends to let others run (fairer)
// Use ensureActive in tight loops for minimal overhead
// Use yield when you want to be fair to other coroutines on the same dispatcher
NonCancellable — critical cleanup that must complete
// PROBLEM: when a coroutine is cancelled, ALL suspend calls throw CancellationException
// Even cleanup code like saving to database or closing a file!
viewModelScope.launch {
try {
processData()
} finally {
saveProgress() // ❌ this is a suspend function
// If the coroutine is cancelled, saveProgress() throws immediately!
// Progress is NOT saved — data lost!
}
}
// SOLUTION: NonCancellable
viewModelScope.launch {
try {
processData()
} catch (e: CancellationException) {
withContext(NonCancellable) {
// NonCancellable is an OBJECT that implements Job
// withContext(NonCancellable) tells the coroutine:
// "even though I'm cancelled, let these suspend calls complete"
saveProgress() // ✅ runs to completion even if cancelled
database.flush() // ✅ runs to completion
file.close() // ✅ runs to completion
}
throw e // re-throw AFTER cleanup is done
}
}
// WHEN to use NonCancellable:
// ✅ Saving partial progress to database
// ✅ Closing file handles / network connections
// ✅ Sending final analytics event
// ✅ Rolling back a transaction
// ❌ DON'T use for normal work — defeats the purpose of cancellation
CoroutineName — Debugging Made Easy
// CoroutineName is a CLASS from kotlinx.coroutines
// It gives your coroutine a human-readable name for debugging
viewModelScope.launch(CoroutineName("article-sync")) {
// In logs and debugger, this coroutine is labeled "article-sync"
// Instead of "coroutine#42" you see "article-sync#42"
println(coroutineContext[CoroutineName]?.name) // "article-sync"
loadArticles()
}
// Enable coroutine debugging to see names in thread names:
// Add to your Application.onCreate() or test setup:
// System.setProperty("kotlinx.coroutines.debug", "on")
// Now thread names show: "DefaultDispatcher-worker-1 @article-sync#42"
// Using CoroutineName in production:
viewModelScope.launch(CoroutineName("load-${screen.name}")) {
// When you see "load-ArticleList#7" in crash logs
// you know EXACTLY which screen and operation caused the crash
}
// Combine with handler for rich error reporting:
val handler = CoroutineExceptionHandler { ctx, throwable ->
val name = ctx[CoroutineName]?.name ?: "unnamed"
crashReporter.log("Coroutine '$name' failed", throwable)
}
viewModelScope.launch(CoroutineName("checkout-payment") + handler) {
processPayment()
// If this crashes → report says "Coroutine 'checkout-payment' failed"
// Much more useful than "coroutine#127 failed"!
}
Dispatchers.Unconfined — The Special Case
// Dispatchers.Unconfined is a PROPERTY on the Dispatchers OBJECT
// It starts on the CALLER's thread, but after suspension resumes on ANY thread
launch(Dispatchers.Unconfined) {
println("1: ${Thread.currentThread().name}") // main thread (caller's)
delay(100)
println("2: ${Thread.currentThread().name}") // kotlinx.coroutines worker thread!
withContext(Dispatchers.IO) { blockingWork() }
println("3: ${Thread.currentThread().name}") // IO thread!
}
// Output:
// 1: main
// 2: kotlinx.coroutines.DefaultExecutor
// 3: DefaultDispatcher-worker-1
// The thread CHANGES after each suspension point — unpredictable!
// WHEN to use Dispatchers.Unconfined:
// ✅ Unit tests — when you don't care about threading
// ✅ Simple transformations that don't need a specific thread
// ❌ NEVER in production Android code
// ❌ NEVER for UI updates (might not be on Main after suspension)
// ❌ NEVER for IO (might end up on Main, blocking it)
// For tests, prefer Dispatchers.Unconfined or TestDispatcher:
@Test
fun `test coroutine`() = runTest {
// runTest uses UnconfinedTestDispatcher by default
// Everything runs synchronously — predictable, fast
}
Putting It All Together — Production ViewModel
@HiltViewModel
class DashboardViewModel @Inject constructor(
private val articleRepository: ArticleRepository,
private val userRepository: UserRepository,
private val notificationRepository: NotificationRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<DashboardState>(DashboardState.Loading)
val uiState: StateFlow<DashboardState> = _uiState.asStateFlow()
// Handler for unexpected errors (NPE, ClassCast, etc.)
private val errorHandler = CoroutineExceptionHandler { ctx, throwable ->
val name = ctx[CoroutineName]?.name ?: "unknown"
analytics.logError("Coroutine '$name' crashed", throwable)
_uiState.value = DashboardState.Error("Unexpected error")
}
init { loadDashboard() }
private fun loadDashboard() {
viewModelScope.launch(errorHandler + CoroutineName("load-dashboard")) {
_uiState.value = DashboardState.Loading
// supervisorScope — each section loads independently
supervisorScope {
val user = async(CoroutineName("load-user")) {
try { userRepository.getUser() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { null }
}
val articles = async(CoroutineName("load-articles")) {
try { articleRepository.getArticles() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { emptyList() }
}
val notifications = async(CoroutineName("load-notifications")) {
try { notificationRepository.getRecent() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { emptyList() }
}
_uiState.value = DashboardState.Success(
user = user.await(),
articles = articles.await(),
notifications = notifications.await()
)
}
}
}
sealed interface DashboardState {
data object Loading : DashboardState
data class Success(
val user: User?,
val articles: List<Article>,
val notifications: List<Notification>
) : DashboardState
data class Error(val message: String) : DashboardState
}
}
Common Mistakes to Avoid
Mistake 1: CoroutineExceptionHandler on a child coroutine
// ❌ Handler on child — IGNORED! Exception goes to parent
viewModelScope.launch {
launch(handler) { // ❌ handler on child
throw Exception("boom") // handler NOT called!
}
}
// ✅ Handler on root coroutine or scope
viewModelScope.launch(handler) { // ✅ handler on root
launch {
throw Exception("boom") // ✅ handler catches
}
}
Mistake 2: Using CoroutineExceptionHandler with async
// ❌ Handler doesn't work with async — exception goes to await()
val result = async(handler) { throw Exception("fail") }
result.await() // 💥 throws HERE, handler was never called
// ✅ try-catch around await()
val result = async { riskyOperation() }
try { result.await() }
catch (e: Exception) { handleError(e) }
Mistake 3: Forgetting to cancel custom scopes
// ❌ Custom scope never cancelled — coroutines leak
class MyService {
private val scope = CoroutineScope(Dispatchers.IO)
fun doWork() { scope.launch { /* runs forever if not cancelled */ } }
// When is scope.cancel() called? NEVER! → memory leak
}
// ✅ Cancel in the component's lifecycle
class MyService : LifecycleObserver {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun doWork() { scope.launch { /* ... */ } }
@OnDestroy fun cleanup() { scope.cancel() } // ✅ clean shutdown
}
Mistake 4: Not re-throwing CancellationException
// ❌ Catches cancellation — coroutine becomes unkillable!
try { suspendFunction() }
catch (e: Exception) { log(e) } // catches CancellationException too!
// ✅ Always re-throw
try { suspendFunction() }
catch (e: CancellationException) { throw e }
catch (e: Exception) { log(e) }
Mistake 5: Using GlobalScope because "it’s easier"
// ❌ GlobalScope — no lifecycle, leaks, hard to test
GlobalScope.launch { syncData() }
// ✅ Use the appropriate scope
viewModelScope.launch { syncData() } // screen-level work
// OR
WorkManager.enqueue(syncRequest) // background work that survives app close
Summary
- CoroutineContext (interface) is the configuration system — holds Elements identified by Keys, combined with
+ - Four key elements: Job (lifecycle), Dispatcher (thread), CoroutineName (debugging), CoroutineExceptionHandler (safety net)
CoroutineName(class) labels coroutines for logs and crash reports — invaluable for debugging production issues- Child coroutines inherit parent context; additional elements override inherited ones (right side wins with
+) - viewModelScope (extension property) = SupervisorJob + Main — cancelled in onCleared()
- GlobalScope (object) has no lifecycle, no structured concurrency — avoid in production
- Structured concurrency: parent waits for children, parent cancellation cancels children, child failure cancels parent+siblings
SupervisorJob()(top-level function) overrides child-failure propagation — siblings keep runningsupervisorScope { }(top-level suspend function) creates supervisor behavior inside a coroutinecoroutineScope= all must succeed;supervisorScope= tasks are independent- CoroutineExceptionHandler (interface) catches unhandled launch exceptions — must be on root coroutine or scope, doesn’t work with async
ensureActive()(extension function) checks cancellation in CPU loops — fast, no suspensionyield()(top-level suspend function) checks cancellation AND suspends to let other coroutines runNonCancellable(object implementing Job) lets critical cleanup code complete even during cancellation- Dispatchers.Unconfined (property) starts on caller’s thread, resumes on any thread — only for tests
- Always re-throw CancellationException and always cancel custom scopes when their owner is destroyed
CoroutineContext is the invisible system that makes coroutines work correctly. Understanding it means you know exactly where your code runs (Dispatcher), how it handles failure (ExceptionHandler, SupervisorJob), when it stops (Job, cancellation), and how to debug it when something goes wrong (CoroutineName). These aren’t advanced topics — they’re the foundation that every production coroutine relies on.
Happy coding!
Comments (0)