Every coroutine you launch creates a Job. Jobs are the backbone of structured concurrency — they define parent-child relationships, control cancellation propagation, and determine how failures in one coroutine affect others. Most developers use launch and viewModelScope without thinking about the Job underneath, until something goes wrong — a child failure kills the entire scope, a cancelled parent leaves orphan work running, or a supervisor doesn’t behave as expected. This guide covers how Jobs work in detail, the parent-child hierarchy, cancellation propagation rules, SupervisorJob, and every edge case you’ll encounter in real Android apps.


What is a Job?

A Job is a handle to a coroutine. It represents the coroutine’s lifecycle and lets you control it — check its state, cancel it, or wait for it to finish:

// Every launch returns a Job
val job: Job = viewModelScope.launch {
    delay(1000)
    println("Done")
}

// Job gives you control
println(job.isActive)      // true — coroutine is running
println(job.isCompleted)   // false — hasn't finished yet
println(job.isCancelled)   // false — hasn't been cancelled

job.cancel()               // request cancellation
println(job.isCancelled)   // true

job.join()                 // suspend until the job completes

job.cancelAndJoin()        // cancel and wait for completion

// async returns a Deferred<T> which extends Job
val deferred: Deferred<String> = async { "result" }
deferred.await()           // suspend until result is ready

Job Lifecycle — States and Transitions

// A Job goes through these states:
//
//                          +----------+
//                          |   New    |    (created with lazy start)
//                          +----+-----+
//                               |
//                          start() / join()
//                               |
//                          +----v-----+
//                    +---->|  Active  |<----+
//                    |     +----+-----+     |
//                    |          |            |
//              child started    |       child completed
//                    |          |            |
//                    |     cancel() / fail   |
//                    |          |            |
//                    |     +----v---------+  |
//                    |     | Cancelling   |  |
//                    |     +----+---------+  |
//                    |          |            |
//                    |     finally done      |
//                    |          |            |
//                    |     +----v---------+  |
//                    +-----|  Completed   |--+
//                          +--------------+
//
// Completed has two sub-states:
//   - Completed normally (isCompleted=true, isCancelled=false)
//   - Cancelled/Failed   (isCompleted=true, isCancelled=true)

val job = viewModelScope.launch {
    println("Active: ${coroutineContext.job.isActive}")   // true
    delay(1000)
    println("Still active: ${coroutineContext.job.isActive}")
}

// After completion:
job.join()
println(job.isCompleted)   // true
println(job.isCancelled)   // false — completed normally

Lazy start

// By default, coroutines start immediately (CoroutineStart.DEFAULT)
val eager = launch { println("Starts immediately") }

// With LAZY, the coroutine is created but doesn't start until triggered
val lazy = launch(start = CoroutineStart.LAZY) {
    println("Starts only when needed")
}

println(lazy.isActive)     // false — hasn't started
lazy.start()               // explicitly start
println(lazy.isActive)     // true

// join() and await() also start a lazy coroutine
val deferred = async(start = CoroutineStart.LAZY) { computeResult() }
val result = deferred.await()   // starts the coroutine AND waits for result

Parent-Child Relationship

When you launch a coroutine inside another coroutine, the inner coroutine’s Job becomes a child of the outer coroutine’s Job. This creates a hierarchy:

val parentJob = viewModelScope.launch {       // parent
    val child1 = launch { delay(1000) }        // child of parent
    val child2 = launch {                      // child of parent
        val grandchild = launch { delay(500) } // child of child2
    }
}

// Hierarchy:
// viewModelScope.coroutineContext[Job]  (scope's job)
//   └── parentJob
//         ├── child1
//         └── child2
//               └── grandchild

// You can verify:
println(child1.parent == parentJob)   // true
println(parentJob.children.toList())  // [child1, child2]

How the parent-child link is established

// The link happens automatically when launching inside a scope/coroutine
viewModelScope.launch {   // Job A (child of viewModelScope's job)
    launch {               // Job B (child of Job A) — inherits context
        launch {           // Job C (child of Job B) — inherits context
        }
    }
}

// The link is established via CoroutineContext inheritance
// Each launch inherits the parent's context (including its Job)
// The new coroutine's Job becomes a child of the parent's Job

// You can BREAK the link by providing a new Job:
viewModelScope.launch {
    launch(Job()) {   // ⚠️ NEW Job — NOT a child of the parent
        // This coroutine is detached from the parent hierarchy
        // Parent cancellation will NOT reach this coroutine
        // This coroutine's failure will NOT affect the parent
    }
}
// ⚠️ This is almost always a mistake — breaks structured concurrency

Three Rules of Structured Concurrency

Rule 1: A parent waits for all children to complete

val parentJob = viewModelScope.launch {
    launch {
        delay(1000)
        println("Child 1 done")
    }
    launch {
        delay(2000)
        println("Child 2 done")
    }
    println("Parent body done")
}

parentJob.join()
println("All done")

// Output:
// Parent body done           ← parent body finishes first
// Child 1 done               ← after 1s
// Child 2 done               ← after 2s
// All done                   ← parent waits for ALL children

// Even though the parent body finishes, the parent Job stays Active
// until ALL children complete. parent.isCompleted becomes true
// only after child1 AND child2 are done.

Rule 2: Cancelling a parent cancels ALL children

val parentJob = viewModelScope.launch {
    val child1 = launch {
        try {
            delay(5000)
            println("Child 1 done")   // never runs
        } finally {
            println("Child 1 cancelled")
        }
    }
    val child2 = launch {
        try {
            delay(5000)
            println("Child 2 done")   // never runs
        } finally {
            println("Child 2 cancelled")
        }
    }
}

delay(100)
parentJob.cancel()   // cancels parent AND both children

// Output:
// Child 1 cancelled
// Child 2 cancelled

// Cancellation flows DOWN the hierarchy:
// parent.cancel() → child1.cancel() + child2.cancel()
// Each child's CancellationException is thrown at next suspension point

Rule 3: A child failure cancels the parent (and siblings)

val parentJob = viewModelScope.launch {
    val child1 = launch {
        delay(100)
        throw RuntimeException("Child 1 failed!")   // 💥 failure
    }
    val child2 = launch {
        try {
            delay(5000)
            println("Child 2 done")   // never runs
        } finally {
            println("Child 2 cancelled")   // child2 gets cancelled
        }
    }
}

// What happens:
// 1. child1 throws RuntimeException
// 2. child1 notifies parent: "I failed"
// 3. Parent cancels ALL other children (child2)
// 4. Parent fails with the same exception
// 5. Parent notifies ITS parent (viewModelScope's job)

// Output:
// Child 2 cancelled

// This is the default behaviour with regular Job
// ONE child failure = EVERYTHING cancels

SupervisorJob — Independent Children

SupervisorJob changes Rule 3: a child’s failure does NOT cancel the parent or siblings. Each child is independent:

val supervisor = CoroutineScope(SupervisorJob() + Dispatchers.Main)

supervisor.launch {
    delay(100)
    throw RuntimeException("Child 1 failed!")   // 💥 failure
}

supervisor.launch {
    delay(1000)
    println("Child 2 completed")   // ✅ runs normally!
}

// With SupervisorJob:
// 1. child1 throws RuntimeException
// 2. child1 fails and completes
// 3. Parent and child2 are NOT affected
// 4. child2 continues to run normally

// Output:
// Child 2 completed

supervisorScope { } — scoped supervisor

// supervisorScope creates a temporary supervisor context
viewModelScope.launch {
    supervisorScope {
        val child1 = launch {
            delay(100)
            throw RuntimeException("Failed!")   // child1 fails
        }
        val child2 = launch {
            delay(1000)
            println("Child 2 still runs")   // ✅ not affected
        }
    }
    // Code here runs AFTER both children complete
    // supervisorScope waits for all children (like coroutineScope)
    println("After supervisorScope")
}

// Output:
// Child 2 still runs
// After supervisorScope

coroutineScope vs supervisorScope

// coroutineScope — one child fails = everything cancels
suspend fun loadAll() = coroutineScope {
    val users = async { fetchUsers() }       // if this fails...
    val articles = async { fetchArticles() } // ...this gets cancelled
    Pair(users.await(), articles.await())
}

// supervisorScope — children are independent
suspend fun loadAll() = supervisorScope {
    val users = async { fetchUsers() }       // if this fails...
    val articles = async { fetchArticles() } // ...this keeps running
    val u = try { users.await() } catch (e: Exception) { emptyList() }
    val a = try { articles.await() } catch (e: Exception) { emptyList() }
    Pair(u, a)
}

// Use coroutineScope when: all-or-nothing (if one fails, all are useless)
// Use supervisorScope when: independent tasks (partial results are OK)

viewModelScope — How It’s Built

// viewModelScope is defined roughly as:
val ViewModel.viewModelScope: CoroutineScope
    get() = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

// Key points:
// 1. SupervisorJob — one failing coroutine doesn't kill the scope
// 2. Dispatchers.Main.immediate — runs on main thread
// 3. Cancelled automatically when ViewModel.onCleared() is called

// This means:
viewModelScope.launch {
    throw RuntimeException("Error!")
}

viewModelScope.launch {
    delay(1000)
    println("Still running!")   // ✅ runs because SupervisorJob
}

// But be careful — SupervisorJob only applies to DIRECT children:
viewModelScope.launch {   // direct child of supervisor
    launch {               // child of the launch above, NOT of supervisor
        throw RuntimeException("Error!")
    }
    launch {
        delay(1000)
        println("This gets cancelled!")   // ❌ cancelled!
    }
}
// The inner launches have a regular Job (not supervisor)
// So the regular Rule 3 applies: one child fails → parent cancels siblings

Exception Propagation — The Full Picture

launch vs async exception handling

// launch — exception propagates UP to parent immediately
viewModelScope.launch {
    throw RuntimeException("Boom!")
    // Exception goes: child → parent → CoroutineExceptionHandler (if any)
    // Cannot be caught with try-catch AROUND the launch
}

// try-catch OUTSIDE launch does NOT work:
try {
    viewModelScope.launch {
        throw RuntimeException("Boom!")   // NOT caught by this try-catch
    }
} catch (e: Exception) {
    // Never reached! launch propagates up, not to the caller
}

// try-catch INSIDE launch works:
viewModelScope.launch {
    try {
        riskyOperation()
    } catch (e: Exception) {
        // ✅ Caught! Exception doesn't propagate to parent
        _error.value = e.message
    }
}


// async — exception is DEFERRED until await()
val deferred = viewModelScope.async {
    throw RuntimeException("Boom!")
}

// Exception is stored, not thrown yet...

try {
    deferred.await()   // exception thrown HERE
} catch (e: Exception) {
    println("Caught: ${e.message}")   // ✅ works
}

// ⚠️ BUT: async ALSO propagates to parent (it's still a child)
// So even if you don't call await(), the parent may still be cancelled
// This is a common gotcha!

The async exception gotcha

// ❌ Common mistake: async inside coroutineScope
suspend fun loadData() = coroutineScope {
    val deferred = async {
        throw RuntimeException("Failed!")
    }
    // Even without calling await(), the exception propagates to coroutineScope
    // coroutineScope cancels everything and re-throws
    delay(1000)
    println("Never reached")
}

// ✅ If you want to handle async failure independently, use supervisorScope
suspend fun loadData() = supervisorScope {
    val deferred = async {
        throw RuntimeException("Failed!")
    }
    delay(1000)
    println("This runs!")   // ✅ supervisor doesn't cancel on child failure
    try {
        deferred.await()
    } catch (e: Exception) {
        println("Handled: ${e.message}")
    }
}

CoroutineExceptionHandler

// Global catch-all for uncaught exceptions in launch
val handler = CoroutineExceptionHandler { _, exception ->
    println("Caught: ${exception.message}")
}

// Must be installed on the SCOPE or the ROOT coroutine
val scope = CoroutineScope(SupervisorJob() + handler + Dispatchers.Main)

scope.launch {
    throw RuntimeException("Unhandled!")
}
// Output: Caught: Unhandled!

// ⚠️ Handler only works with launch, NOT async
// ⚠️ Handler only catches exceptions from ROOT coroutines
// It does NOT catch exceptions from child coroutines of a regular Job

// ❌ Does NOT work — handler on child coroutine
scope.launch {
    launch(handler) {   // handler on child is IGNORED
        throw RuntimeException("Not caught by handler!")
    }
}

// ✅ Works — handler on root coroutine or scope
scope.launch(handler) {   // handler on root coroutine
    throw RuntimeException("Caught!")
}

Edge Cases and Gotchas

Edge case 1: Passing Job() breaks structured concurrency

// ❌ This coroutine is NOT a child of viewModelScope
viewModelScope.launch(Job()) {
    delay(10_000)
    println("Still running after ViewModel is destroyed!")
}
// Job() creates a new parent — this coroutine is detached
// viewModelScope.cancel() does NOT cancel it
// This causes memory leaks!

// ✅ Don't pass Job() — let structured concurrency handle it
viewModelScope.launch {
    delay(10_000)
    println("Properly cancelled when ViewModel clears")
}

Edge case 2: SupervisorJob only works for direct children

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

scope.launch {   // Direct child of supervisor ← supervisor rules apply HERE
    // Inside this launch, it's a REGULAR Job
    val child1 = launch {
        throw RuntimeException("Fails!")
    }
    val child2 = launch {
        delay(1000)
        println("This gets cancelled!")   // ❌ cancelled by child1's failure
    }
}

// The supervisor protects scope-level children from each other
// But WITHIN a single launch block, regular Job rules apply

// ✅ If you need independent children inside a coroutine, use supervisorScope
scope.launch {
    supervisorScope {
        launch { throw RuntimeException("Fails!") }
        launch {
            delay(1000)
            println("This runs!")   // ✅ independent
        }
    }
}

Edge case 3: CancellationException is treated specially

// CancellationException does NOT propagate as a failure
viewModelScope.launch {
    val child1 = launch {
        delay(100)
        throw CancellationException("I'm done")   // cancellation, not failure
    }
    val child2 = launch {
        delay(1000)
        println("Child 2 still runs!")   // ✅ not affected
    }
}

// CancellationException cancels the child but does NOT:
// - Cancel the parent
// - Cancel sibling coroutines
// - Trigger CoroutineExceptionHandler

// This is why you must ALWAYS re-throw CancellationException:
launch {
    try {
        suspendingCall()
    } catch (e: Exception) {
        // ❌ This catches CancellationException too!
        // The coroutine thinks it's still running
        log(e)
    }
}

// ✅ Always separate CancellationException
launch {
    try {
        suspendingCall()
    } catch (e: CancellationException) {
        throw e   // re-throw!
    } catch (e: Exception) {
        log(e)
    }
}

Edge case 4: Parent cancellation during finally

// When a parent cancels a child, the child's finally block runs
// BUT the child is already in cancelling state
// So suspend calls in finally throw CancellationException immediately

viewModelScope.launch {
    try {
        delay(Long.MAX_VALUE)
    } finally {
        // Coroutine is cancelled here
        println("Cleanup starting")
        delay(100)   // ❌ throws CancellationException immediately!
        println("Never reached")
    }
}

// ✅ Use withContext(NonCancellable) for suspending cleanup
viewModelScope.launch {
    try {
        delay(Long.MAX_VALUE)
    } finally {
        withContext(NonCancellable) {
            println("Cleanup starting")
            delay(100)   // ✅ runs even though coroutine is cancelled
            saveState()  // ✅ suspending cleanup works
            println("Cleanup done")
        }
    }
}

Edge case 5: cancel vs cancel(cause)

val job = launch { delay(Long.MAX_VALUE) }

// cancel() — default CancellationException
job.cancel()
// job.getCancellationException().message == "Job was cancelled"

// cancel(CancellationException("reason")) — custom message
job.cancel(CancellationException("User navigated away"))

// cancel(cause) where cause is NOT CancellationException
// This is treated as a FAILURE, not normal cancellation
// ⚠️ Different behaviour — propagates as failure in some cases

// Best practice: always use CancellationException as the cause
job.cancel(CancellationException("Specific reason for debugging"))

Edge case 6: Job completion order

// Children complete BEFORE parent
val parent = launch {
    val child = launch {
        delay(100)
        println("1. Child completes")
    }
    println("2. Parent body done")
    // Parent waits here for child to complete
}

parent.invokeOnCompletion {
    println("3. Parent completes")
}

// Output:
// 2. Parent body done        ← parent body finishes first
// 1. Child completes          ← child completes
// 3. Parent completes         ← parent completes AFTER child

// invokeOnCompletion runs when the Job reaches a final state
// This includes normal completion, cancellation, or failure

Edge case 7: Multiple children failing simultaneously

val handler = CoroutineExceptionHandler { _, e ->
    println("Handler: ${e.message}")
    e.suppressed.forEach { println("  Suppressed: ${it.message}") }
}

val scope = CoroutineScope(handler + Job())

scope.launch {
    launch {
        delay(100)
        throw RuntimeException("Error A")
    }
    launch {
        delay(100)
        throw RuntimeException("Error B")
    }
}

// One exception becomes the "main" exception
// The other is added as a SUPPRESSED exception
// Output:
// Handler: Error A
//   Suppressed: Error B

// Which one is "main" depends on timing
// Both are available via exception.suppressed

Real Android Patterns

Independent parallel loads with partial failure handling

class DashboardViewModel(
    private val userRepo: UserRepository,
    private val articleRepo: ArticleRepository,
    private val statsRepo: StatsRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(DashboardState())
    val uiState: StateFlow<DashboardState> = _uiState

    fun loadDashboard() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            // supervisorScope: each load is independent
            supervisorScope {
                val userDeferred = async { userRepo.getCurrentUser() }
                val articlesDeferred = async { articleRepo.getLatest() }
                val statsDeferred = async { statsRepo.getDashboardStats() }

                // Await each independently — one failure doesn't kill others
                val user = try {
                    userDeferred.await()
                } catch (e: CancellationException) { throw e }
                catch (e: Exception) { null }

                val articles = try {
                    articlesDeferred.await()
                } catch (e: CancellationException) { throw e }
                catch (e: Exception) { emptyList() }

                val stats = try {
                    statsDeferred.await()
                } catch (e: CancellationException) { throw e }
                catch (e: Exception) { null }

                _uiState.value = DashboardState(
                    user = user,
                    articles = articles,
                    stats = stats,
                    isLoading = false,
                    userError = if (user == null) "Failed to load profile" else null,
                    statsError = if (stats == null) "Failed to load stats" else null
                )
            }
        }
    }
}

Cancellable job references in ViewModel

class SearchViewModel(private val repository: SearchRepository) : ViewModel() {

    private val _results = MutableStateFlow<List<Result>>(emptyList())
    val results: StateFlow<List<Result>> = _results

    private var searchJob: Job? = null
    private var filterJob: Job? = null

    fun search(query: String) {
        searchJob?.cancel()   // cancel previous search only
        searchJob = viewModelScope.launch {
            delay(300)   // debounce
            _results.value = repository.search(query)
        }
    }

    fun applyFilter(filter: Filter) {
        filterJob?.cancel()   // cancel previous filter only
        filterJob = viewModelScope.launch {
            _results.value = repository.applyFilter(filter)
        }
    }

    // searchJob and filterJob are independent
    // Cancelling search doesn't cancel filter and vice versa
    // viewModelScope cancellation cancels both (via structured concurrency)
}

Fire-and-forget with proper error handling

class ArticleViewModel(
    private val repository: ArticleRepository,
    private val analytics: Analytics
) : ViewModel() {

    fun onArticleViewed(articleId: String) {
        // Fire-and-forget: don't care if analytics fails
        viewModelScope.launch {
            try {
                analytics.trackView(articleId)
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                // Silently ignore analytics failure
                // viewModelScope uses SupervisorJob, so this
                // wouldn't kill the scope anyway
            }
        }
    }

    fun bookmarkArticle(articleId: String) {
        viewModelScope.launch {
            try {
                repository.bookmark(articleId)
                _events.emit(UiEvent.ShowSnackbar("Bookmarked!"))
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                _events.emit(UiEvent.ShowSnackbar("Failed to bookmark"))
            }
        }
    }
}

Custom scope with SupervisorJob for a specific feature

class DownloadManager(private val api: Api) {

    // Custom scope for downloads — SupervisorJob so one failed download
    // doesn't cancel all others
    private val downloadScope = CoroutineScope(
        SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, e ->
            Log.e("DownloadManager", "Download failed: ${e.message}")
        }
    )

    private val activeDownloads = mutableMapOf<String, Job>()

    fun download(url: String): Job {
        // Cancel existing download for same URL
        activeDownloads[url]?.cancel()

        val job = downloadScope.launch {
            val data = api.download(url)
            saveToFile(url, data)
        }

        activeDownloads[url] = job

        job.invokeOnCompletion { cause ->
            activeDownloads.remove(url)
            when {
                cause == null -> Log.d("DL", "$url completed")
                cause is CancellationException -> Log.d("DL", "$url cancelled")
                else -> Log.e("DL", "$url failed: ${cause.message}")
            }
        }

        return job
    }

    fun cancelDownload(url: String) {
        activeDownloads[url]?.cancel()
    }

    fun cancelAll() {
        downloadScope.coroutineContext.cancelChildren()
        // cancelChildren() cancels all children but keeps the scope alive
        // scope.cancel() would kill the scope permanently
    }

    fun shutdown() {
        downloadScope.cancel()   // permanent — scope can't be reused
    }
}

Common Mistakes to Avoid

Mistake 1: Passing Job() to a coroutine builder

// ❌ Breaks parent-child relationship
viewModelScope.launch(Job()) {
    // This coroutine is NOT cancelled when ViewModel is destroyed
    // Memory leak!
}

// ❌ Also breaks it
viewModelScope.launch(SupervisorJob()) {
    // Same problem — detached from viewModelScope's job
}

// ✅ Let structured concurrency work
viewModelScope.launch {
    // Automatically cancelled when ViewModel clears
}

// ✅ If you need supervisor behaviour INSIDE a coroutine
viewModelScope.launch {
    supervisorScope {
        // Children are independent here
    }
}

Mistake 2: Expecting SupervisorJob to protect grandchildren

// ❌ SupervisorJob only protects DIRECT children
val scope = CoroutineScope(SupervisorJob())

scope.launch {   // child of supervisor
    launch { throw RuntimeException("A") }   // grandchild
    launch { println("B") }                   // grandchild — gets cancelled!
}

// ✅ Use supervisorScope where you need it
scope.launch {
    supervisorScope {
        launch { throw RuntimeException("A") }
        launch {
            delay(1000)
            println("B")   // ✅ independent
        }
    }
}

Mistake 3: Using scope.cancel() when you mean cancelChildren()

// ❌ cancel() kills the scope permanently
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.cancel()   // scope is dead — can't launch new coroutines

scope.launch { }   // silently does nothing or throws

// ✅ cancelChildren() cancels running work but keeps scope alive
scope.coroutineContext.cancelChildren()
scope.launch { }   // ✅ works — scope is still alive

Mistake 4: Forgetting that async propagates exceptions to parent

// ❌ Exception propagates even without await()
coroutineScope {
    val deferred = async {
        throw RuntimeException("Boom!")
    }
    delay(1000)
    // coroutineScope is already cancelled by the async failure
    // This line never runs
}

// ✅ Use supervisorScope if you want to handle async failure at await
supervisorScope {
    val deferred = async {
        throw RuntimeException("Boom!")
    }
    delay(1000)
    try {
        deferred.await()
    } catch (e: Exception) {
        println("Handled: ${e.message}")
    }
}

Mistake 5: Not using invokeOnCompletion for cleanup

// ❌ Cleanup might not run if coroutine is cancelled before reaching it
viewModelScope.launch {
    acquireResource()
    doWork()
    releaseResource()   // might never run!
}

// ✅ Use try-finally or invokeOnCompletion
viewModelScope.launch {
    try {
        acquireResource()
        doWork()
    } finally {
        withContext(NonCancellable) {
            releaseResource()   // always runs
        }
    }
}

// Or with invokeOnCompletion on the Job
val job = viewModelScope.launch {
    acquireResource()
    doWork()
}
job.invokeOnCompletion {
    releaseResource()   // runs when job completes, cancels, or fails
    // ⚠️ This runs on whatever thread the job completed on
    // Don't do heavy work here
}

Summary

  • A Job is a handle to a coroutine — it tracks lifecycle state (New, Active, Cancelling, Completed) and enables cancellation
  • Every launch creates a child Job linked to its parent — this is the parent-child hierarchy
  • Rule 1: A parent waits for all children to complete before it completes
  • Rule 2: Cancelling a parent cancels all children (cancellation flows down)
  • Rule 3: A child failure cancels the parent and all siblings (failure flows up) — with regular Job
  • SupervisorJob changes Rule 3: child failures are isolated — siblings and parent are not affected
  • supervisorScope { } creates a temporary supervisor context inside a coroutine
  • viewModelScope uses SupervisorJob — but only for direct children, not grandchildren
  • CancellationException is not treated as failure — it cancels the child but doesn’t propagate to the parent
  • Never pass Job() or SupervisorJob() to a coroutine builder — it breaks structured concurrency
  • async propagates exceptions to the parent even without calling await()
  • CoroutineExceptionHandler only catches exceptions from root coroutines launched with launch
  • Use cancelChildren() to cancel all children but keep the scope alive — cancel() kills the scope permanently
  • invokeOnCompletion runs a callback when a Job finishes — useful for cleanup and monitoring

Jobs and structured concurrency are the framework that makes coroutines safe and predictable. The parent-child hierarchy ensures no coroutine is ever forgotten. SupervisorJob gives you controlled independence when you need it. And understanding the edge cases — especially how SupervisorJob only protects direct children, how async propagates to parent, and why passing Job() is dangerous — is what prevents the subtle bugs that haunt production coroutine code.

Happy coding!