Scopes and Structured Concurrency
Coroutine Scopes
A coroutine scope is the boundary that controls a coroutine's lifetime. When the scope is cancelled, every coroutine inside it is cancelled. This is the foundation of structured concurrency — no coroutine outlives the scope that created it.
Android provides three built-in scopes:
- viewModelScope: Bound to a ViewModel's lifecycle. Automatically cancels when the ViewModel is cleared.
- lifecycleScope: Bound to a Lifecycle owner. Cancels when the Activity or Fragment reaches DESTROYED.
- GlobalScope: Not bound to any lifecycle. Coroutines launched here live until they finish or the process dies. Avoid this in production code.
class SearchViewModel : ViewModel() {
fun search(query: String) {
viewModelScope.launch {
// This coroutine is cancelled if the ViewModel is cleared
val results = repository.search(query)
_uiState.value = results
}
}
}
Structured Concurrency Rules
Structured concurrency enforces three guarantees:
- Parent waits for children: A parent coroutine does not complete until all its child coroutines finish.
- Children inherit cancellation: When a parent is cancelled, all children are cancelled.
- Child exceptions propagate: An unhandled exception in a child cancels the parent and all siblings.
suspend fun processUserData() = coroutineScope {
val profile = async { fetchProfile() } // child 1
val orders = async { fetchOrders() } // child 2
// If fetchProfile() throws, fetchOrders() is also cancelled
render(profile.await(), orders.await())
}
SupervisorJob
By default, one child's failure cancels all siblings. SupervisorJob changes this — each child handles its own errors independently.
val supervisorJob = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Main + supervisorJob)
scope.launch {
throw RuntimeException("Child 1 fails")
}
scope.launch {
delay(100)
println("Child 2 still running") // This runs!
}
viewModelScope and lifecycleScope already use SupervisorJob, so individual tasks in a ViewModel don't cancel each other on failure.
Dispatchers In Depth
The Four Dispatchers
A dispatcher determines which thread or thread pool a coroutine runs on. Choosing the wrong dispatcher is the most common source of bugs and ANRs.
| Dispatcher | Thread Pool | Use Case |
|---|---|---|
Dispatchers.Main |
UI thread | UI updates, View interactions |
Dispatchers.IO |
Shared pool (up to 64 threads) | Disk I/O, network calls, database |
Dispatchers.Default |
CPU-bound pool (equals CPU cores) | Sorting, parsing, image processing |
Dispatchers.Unconfined |
Caller thread (not confined) | Testing only — avoid in production |
viewModelScope.launch(Dispatchers.Main) {
// On UI thread — update a list adapter
adapter.submitList(items)
val data = withContext(Dispatchers.IO) {
// Switch to IO pool for database read
database.userDao().getAll()
}
val processed = withContext(Dispatchers.Default) {
// Switch to CPU pool for heavy computation
data.sortedBy { it.timestamp }
}
// Back on Main thread
adapter.submitList(processed)
}
Switching Dispatchers with withContext
withContext is a suspend function that temporarily moves a coroutine to a different dispatcher and returns to the original dispatcher when the block completes. This is the idiomatic way to switch contexts.
suspend fun syncData(): Result {
return withContext(Dispatchers.IO) {
val remote = api.fetchLatest()
val local = database.getAll()
val diff = computeDiff(remote, local)
database.upsert(diff)
Result.Success(diff.size)
}
// Automatically returns to the caller's dispatcher
}
Common Mistakes
- Running IO on Main: Network or database calls on
Dispatchers.Maincause ANRs. - Running CPU work on IO: Heavy computation on
Dispatchers.IOstarves the IO pool. - Using GlobalScope: Leads to coroutines that outlive their intended lifecycle.
- Blocking the Main thread: Never use
Thread.sleep()or.join()without switching dispatchers.
Exception Handling in Coroutines
How Exceptions Propagate
Coroutine exception propagation follows structured concurrency rules. An unhandled exception in a coroutine launched with launch is thrown in the scope's job, which cancels the entire scope. An exception in async is stored and rethrown when await() is called.
// launch: exception propagates immediately
viewModelScope.launch {
throw IllegalStateException("Something broke") // Cancels the scope
}
// async: exception is deferred
val deferred = viewModelScope.async {
throw IllegalStateException("Something broke") // Stored
}
deferred.await() // Re-throws here
CoroutineExceptionHandler
CoroutineExceptionHandler is the last-resort handler for uncaught exceptions. It must be installed on the scope that launches the coroutine, not inside the coroutine itself.
val handler = CoroutineExceptionHandler { _, throwable ->
Log.e("CoroutineError", "Uncaught: ${throwable.message}")
_uiState.value = UiState.Error(throwable)
}
viewModelScope.launch(handler) {
throw RuntimeException("Network failure")
// Handler catches it, scope is cancelled
}
Important: CoroutineExceptionHandler does not prevent scope cancellation. It only catches the exception after the scope is already cancelled. If you need to continue processing, launch a new coroutine.
RunCatching Approach
For suspend functions, runCatching wraps the result in a Result type, giving you explicit control over success and failure.
suspend fun fetchData(): UiState {
return runCatching {
api.fetchItems()
}.fold(
onSuccess = { UiState.Success(it) },
onFailure = { UiState.Error(it.message ?: "Unknown error") }
)
}
This is preferred over try-catch in coroutines because it doesn't interfere with structured concurrency — the caller decides whether the failure is terminal.
Quiz
1. What happens when a coroutine launched inside `viewModelScope` throws an uncaught exception?
2. Which dispatcher should you use for reading a large file from internal storage?
3. Why is `SupervisorJob` used in `viewModelScope`?
4. What is the primary difference between `launch` and `async` regarding exception handling?
Flashcards
Question
What does `coroutineScope` do that `GlobalScope` does not?
Click to reveal answer
Answer
coroutineScope creates a new scope that inherits structured concurrency rules — children are cancelled when it completes or fails. GlobalScope coroutines are fire-and-forget with no parent-child relationship.
Question
When should you use Dispatchers.Default vs Dispatchers.IO?
Click to reveal answer
Answer
Dispatchers.Default is for CPU-bound work (sorting, parsing, image processing). Dispatchers.IO is for blocking I/O (disk, network, database). Default is sized to CPU cores; IO has up to 64 threads.
Question
Does CoroutineExceptionHandler prevent scope cancellation?
Click to reveal answer
Answer
No. It only catches the exception after the scope is already cancelled. It does not restart the scope or its coroutines.
Question
What are the three rules of structured concurrency?
Click to reveal answer
Answer
1) Parent waits for children to finish, 2) Children inherit parent cancellation, 3) Child exceptions propagate to the parent and cancel siblings (unless SupervisorJob is used).
Revision Notes
Key Takeaways
- 1. Structured concurrency prevents leaked coroutines by tying their lifetime to a scope
- 2. Choose Dispatchers.IO for blocking I/O and Default for CPU-bound work to avoid starving thread pools
- 3. CoroutineExceptionHandler catches exceptions but does not prevent scope cancellation
- 4. SupervisorJob lets child coroutines fail independently without killing siblings
Interview Tips
- • Explain structured concurrency using a parent-child tree analogy — interviewer-friendly
- • Know the difference between launch (fire-and-forget) and async (deferred result)
- • Be ready to explain why GlobalScope is dangerous — it bypasses lifecycle management
- • When asked about ANR, mention using Dispatchers.IO for blocking operations
Cheat Sheet
Coroutines Deep Dive Cheat Sheet
Scopes:
viewModelScope— ViewModel lifecycle, uses SupervisorJoblifecycleScope— Activity/Fragment lifecycleGlobalScope— Avoid in production
Dispatchers:
- Main → UI thread
- IO → Disk/network/database (up to 64 threads)
- Default → CPU-bound work (CPU core count)
Structured Concurrency:
- Parent waits for children
- Children inherit cancellation
- Exceptions propagate to parent
- SupervisorJob isolates child failures
Exception Handling:
- CoroutineExceptionHandler: last-resort, scope still cancelled
- runCatching: returns Result, explicit control
- try-catch: works but prefer runCatching in suspend functions
Common Mistakes:
- Running IO on Main → ANR
- Using GlobalScope → leaked coroutines
- Blocking Main with Thread.sleep → UI freeze