Skip to content
advanced Phase 9 · Background Processing

Coroutines Deep Dive

Master coroutine scopes, dispatchers, exception handling, and structured concurrency in Android.

55m
3 problems
Topic Progress 0%

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:

  1. Parent waits for children: A parent coroutine does not complete until all its child coroutines finish.
  2. Children inherit cancellation: When a parent is cancelled, all children are cancelled.
  3. 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

  1. Running IO on Main: Network or database calls on Dispatchers.Main cause ANRs.
  2. Running CPU work on IO: Heavy computation on Dispatchers.IO starves the IO pool.
  3. Using GlobalScope: Leads to coroutines that outlive their intended lifecycle.
  4. 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?

Question 1 options

2. Which dispatcher should you use for reading a large file from internal storage?

Question 2 options

3. Why is `SupervisorJob` used in `viewModelScope`?

Question 3 options

4. What is the primary difference between `launch` and `async` regarding exception handling?

Question 4 options

Flashcards

Question

What does `coroutineScope` do that `GlobalScope` does not?

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?

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?

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?

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 SupervisorJob
  • lifecycleScope — Activity/Fragment lifecycle
  • GlobalScope — 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