The Rules of the Kitchen


In Part 1, we saw how launch and async allow us to perform multiple tasks concurrently (like a chef delegating orders). The kitchen is busy, and food is getting out faster. Professional kitchen requires more than just concurrent cooks. It needs structure, management, and protocols when things go wrong.

Consider these scenarios:

  • A customer cancels their order halfway through. Do we keep cooking their meal?
  • A critical piece of equipment (like an oven) fails. Does the entire kitchen shut down?
  • Specific tasks need to be done at specific stations (chopping vs plating).

These are the exact problems that CoroutineScope, Dispatchers, and the principle of Structured Concurrency solve. They are the management system that turns a chaotic collection of tasks into a professional and resilient operation.

The Kitchen Stations: Dispatchers


So far, our coroutines have been running on some background thread, but we haven’t controlled which one. Dispatchers are like the special stations in a kitchen. They tell a coroutine which thread it should run on.

Kotlin gives us three primary dispatchers:

  • Dispatchers.Main: The front of the house (where food is plated and served to the customer). This is the UI thread (on Android). Use it for any task that touches the user interface. It’s optimized for very short, fast operations. Never block this thread!
  • Dispatchers.IO: The storage. This is for I/O-intensive work, like making network calls, reading from a database, or accessing files. It maintains a large pool of threads designed for tasks that spend most of their time waiting.
  • Dispatchers.Default: The main prep area. This is for CPU-intensive work, like sorting a huge list, doing complex calculations, or parsing large JSON objects. Its thread pool is sized to the number of CPU cores.

To move a task between stations, we use the withContext function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking {
    println("Head Chef: 'Get me the user data!' (Starts on Main thread)")

    launch(Dispatchers.Main) {
        // Start on the Main
        println("UI Chef: 'Showing loading spinner.' " +
                "[Thread: ${Thread.currentThread().name}]")

        // Switch to the IO dispatcher to fetch data without blocking the UI
        val userData = withContext(Dispatchers.IO) {
            println("Data Chef: 'Fetching user data...' " +
                    "[Thread: ${Thread.currentThread().name}]")
            delay(1000)
            "User Data Fetched"
        }

        // withContext automatically switches back to the Main thread to update the UI
        println("UI Chef: 'Hiding spinner, showing data: $userData' " +
                "[Thread: ${Thread.currentThread().name}]")
    }
}

Output:

1
2
3
4
5
Head Chef: 'Get me the user data!' (Starts on Main thread)
UI Chef: 'Showing loading spinner.' [Thread: main]
Data Chef: 'Fetching user data...' [Thread: DefaultDispatcher-worker-1]
(1 second passes)
UI Chef: 'Hiding spinner, showing data: User Data Fetched' [Thread: main]

withContext is a suspend function that lets us switch to a different context for a specific block of code. After code is finished, it switches back. This is a fundamental pattern for async programming with coroutines.

Dispatchers

The Head Chef: CoroutineScope and Structured Concurrency


If Dispatchers are the stations, who is in charge of all the cooks? This is the job of the CoroutineScope. A scope is like a head chef for a set of coroutines. It manages their overall lifecycle.

This brings us to the principle of Structured Concurrency. New coroutines can only be launched within a scope. Scope defines their lifetime. When the scope’s lifetime ends, all coroutines within it are automatically cancelled. This prevents coroutines from leaking (e.g. continuing to fetch data for a screen that’s no longer visible).

Framework-Provided Scopes

On Android, you get pre-made scopes tied to component lifecycles, which you should almost always use:

  • viewModelScope: Tied to a ViewModel. It cancels all coroutines when the ViewModel is cleared. This is the default choice in Android.
  • lifecycleScope: Tied to an Activity or Fragment’s Lifecycle. Useful for work that needs to align with specific lifecycle events.

Warning: Avoid GlobalScope GlobalScope is like a rogue chef who works independently and never goes home when the kitchen closes. Coroutines launched in it are not tied to any job and can easily lead to memory leaks and wasted resources. There is almost no good reason to use it in application code.

Job vs. SupervisorJob: How the Chef Handles Failure

A CoroutineScope is defined by its CoroutineContext, which must include a Job. The Job represents the scope’s own lifecycle and how it handles failures.

The default Job has a strict “all for one, one for all” policy. If any child coroutine fails with an exception, it immediately cancels its parent Job and all of its siblings. It’s like a head chef who evacuates the entire section if one cook starts a fire.

Cancel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fun main() = runBlocking {
    val chefScope = CoroutineScope(Job())

    chefScope.launch {
        delay(200)
        println("Cook A: 'Plating the salad.'")
    }
    chefScope.launch {
        delay(100)
        println("Cook B: 'Oh no, I burned the toast!'")
        throw Exception("Toast is on fire!")
    }

    delay(500)
    println("Kitchen Manager: 'Strict chef's section is quiet now.'")
}

Output:

1
2
3
Cook B: 'Oh no, I burned the toast!'
(Exception is thrown, the scope is cancelled)
Kitchen Manager: 'Strict chef's section is quiet now.'

Notice “Cook A” never got to finish. Cook B’s failure cancelled the entire scope.

But what if you want one failure to not affect other tasks? For that, you use a SupervisorJob. It allows child coroutines to fail independently without bringing down the whole scope This is the more lenient head chef who deals with the one cook’s mistake while telling everyone else to keep working.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fun main() = runBlocking {
    val chefScope = CoroutineScope(SupervisorJob())

    chefScope.launch {
        delay(200)
        println("Cook A: 'Plating the salad.'")
    }
    chefScope.launch {
        try {
            delay(100)
            println("Cook B: 'Oh no, I burned the toast!'")
            throw Exception("Toast is on fire!")
        } catch (e: Exception) {
            println("Cook B (to manager): '${e.message}'")
        }
    }

    delay(500)
    println("Kitchen Manager: 'Lenient chef's section is still running.'")
}

Output:

1
2
3
4
Cook B: 'Oh no, I burned the toast!'
Cook B (to manager): 'Toast is on fire!'
Cook A: 'Plating the salad.'
Kitchen Manager: 'Lenient chef's section is still running.'

viewModelScope uses a SupervisorJob by default, which is why one failing network call in a ViewModel doesn’t necessarily stop another one from completing. This makes it incredibly robust for UI-related tasks.

Handling Emergencies: Exception Handling


What happens when a task fails with an error? In the world of coroutines, exceptions propagate up the hierarchy. An uncaught exception will cancel its parent scope (unless it’s a SupervisorJob). This is a “fail-fast” safety feature.

To handle errors gracefully without crashing the scope, use a try-catch block (typically around the .await() call).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() = runBlocking {
    val scope = CoroutineScope(Dispatchers.Default)

    scope.launch {
        println("Chef: 'I'll get that steak from the freezer.'")
        
        val deferredSteak = async<String> {
            delay(500)
            throw Exception("Oh no, the freezer is broken!")
        }
        
        try {
            val steak = deferredSteak.await()
            println("Chef: 'Got the steak: $steak'")
        } catch (e: Exception) {
            println("Chef: 'Error! I'll tell the customer we're out of steak.'")
        }
    }
    
    delay(1000)
}

Output:

1
2
Chef: 'I'll get that steak from the freezer.'
Chef: 'Error! Oh no, the freezer is broken!. I'll tell the customer we're out of steak.'

By catching the exception, we handled the problem locally and allowed the operation to finish gracefully.

Wrap-up

Our kitchen is now managed. We have:

  • Dispatchers: The special stations that ensure work happens in the right place (Main, IO, Default).
  • CoroutineScope: The Head Chef who manages the lifecycle of all tasks. It ensures nothing gets leaked.
  • Job vs SupervisorJob: Different management styles for handling failure.
  • Cancellation & Exceptions: Clear protocols for when an order is cancelled or something goes wrong.

What’s Next in Part 3?

So far, our chefs have been preparing one-off orders. The customer asks for one thing, and we await one result. What happens when we need a continuous stream of dishes for a tasting menu, or a buffet that needs constant refilling?

In Part 3, we’ll introduce a core concept for handling streams of data: Flow. We’ll learn how to emit, transform, and collect series of values over time, taking our kitchen’s capabilities to the next level.