Chapter 24: Coroutines – Structured Concurrency & Flow
Hey Webliance! Welcome to Chapter 24: Coroutines – Structured Concurrency & Flow — this is where coroutines go from “cool” to absolutely essential in Kotlin! ☕🚀
In the last chapter, we covered the basics (suspend functions, runBlocking, launch, async/await). Now, we’ll dive deeper into structured concurrency (how to organize and supervise coroutines safely) and Flow (Kotlin’s reactive streams for handling asynchronous data flows — like RxJava but simpler and built-in).
We’ll cover:
- CoroutineScope & SupervisorJob
- withContext & Dispatchers
- Cold & hot flows (flow { }, StateFlow, SharedFlow)
- Operators (map, filter, collect, catch, etc.)
I’ll explain everything very slowly, like we’re sitting together in a quiet Bandra café — with real-life analogies, complete runnable examples (copy-paste into IntelliJ with coroutines dependency), step-by-step breakdowns, tables, common mistakes with fixes, and fun facts so everything sticks perfectly.
Important setup reminder:
- Add implementation(“org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0”) to your build.gradle.kts
- For Flow, it’s included in core — no extra dependency needed.
Let’s start!
1. CoroutineScope – The Safe Way to Group Coroutines
A CoroutineScope is a context that defines the lifecycle of a group of coroutines. It’s like a parent container that:
- Waits for all child coroutines to finish
- Cancels all children if one fails (structured concurrency)
- Handles exceptions safely
Why use CoroutineScope?
- Avoids leaks (forgotten coroutines running forever)
- Ensures structured concurrency — children don’t outlive parent
- Replaces runBlocking in non-blocking code
Real-life analogy: You’re a project manager (scope) with a team (coroutines).
- You start tasks (launch/async)
- You wait for all to finish before closing the project
- If one team member fails, you stop the whole project
Example 1 – Basic CoroutineScope
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import kotlinx.coroutines.* fun main() = runBlocking { // Outer scope val myScope = CoroutineScope(Dispatchers.Default) // New scope myScope.launch { delay(1000) println("Task 1 done") } myScope.launch { delay(2000) println("Task 2 done") } myScope.coroutineContext.job.join() // Wait for all children println("All tasks in myScope done!") } |
Output (after 2 seconds):
|
0 1 2 3 4 5 6 7 8 |
Task 1 done Task 2 done All tasks in myScope done! |
Step-by-step:
- CoroutineScope(Dispatchers.Default) → creates a new scope on Default dispatcher (background threads)
- launch inside scope → child coroutines
- myScope.coroutineContext.job.join() → waits for all children (or use myScope.job.children.forEach { it.join() })
Best practice: Always cancel the scope when done (e.g., in Android ViewModel onCleared() → scope.cancel()).
2. SupervisorJob – Handle Failures Without Canceling Siblings
SupervisorJob is a special Job that:
- Does not cancel siblings when one child fails
- Propagates exceptions to parent
Use when: You have independent tasks — one failure shouldn’t stop others (e.g., multiple downloads).
Normal Job vs SupervisorJob:
| Type | If one child fails… | Use for |
|---|---|---|
| Normal Job | Cancels all siblings + propagates exception | Dependent tasks (all or nothing) |
| SupervisorJob | Other siblings continue | Independent tasks (e.g., multiple API calls) |
Example 2 – SupervisorJob in action
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
fun main() = runBlocking { val supervisor = SupervisorJob() val scope = CoroutineScope(supervisor) scope.launch { delay(1000) println("Task 1 done") } scope.launch { delay(500) throw Exception("Task 2 failed!") } scope.launch { delay(1500) println("Task 3 done") } try { scope.coroutineContext.job.join() // Wait for all } catch (e: Exception) { println("Caught: ${e.message}") } println("Scope finished") } |
Output (after 1.5 seconds):
|
0 1 2 3 4 5 6 7 8 9 10 |
Task 2 failed! (exception thrown) Task 1 done Task 3 done Caught: Task 2 failed! Scope finished |
Step-by-step:
- SupervisorJob() → creates a job that supervises children independently
- Task 2 fails → doesn’t cancel Task 1/3
- Parent catches the exception from supervisor
Fun fact: In Android, viewModelScope uses SupervisorJob by default — one ViewModel task failing doesn’t kill others.
3. withContext – Switch Dispatchers Inside Coroutine
withContext is a suspend function that:
- Switches to a different dispatcher (thread pool)
- Suspends until the block finishes
- Returns the result of the block
Dispatchers (thread pools):
| Dispatcher | Purpose | Use for |
|---|---|---|
| Dispatchers.Default | CPU-intensive tasks | Heavy computation, list processing |
| Dispatchers.IO | I/O tasks (files, network, database) | API calls, file read/write |
| Dispatchers.Main | UI updates (Android only) | Update UI views |
| Dispatchers.Unconfined | Run on current thread (rare) | Testing / special cases |
Example 3 – withContext to switch dispatchers
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
suspend fun fetchData(): String { withContext(Dispatchers.IO) { // Switch to IO dispatcher delay(2000) // Simulate network call "Data from network" } } fun main() = runBlocking { val data = fetchData() println("Main thread received: $data") withContext(Dispatchers.Main) { // For Android UI // Update UI } } |
Step-by-step:
- withContext(Dispatchers.IO) { … } → moves the block to IO thread pool
- Suspends the coroutine → frees caller thread
- When done, resumes on caller dispatcher → returns result
Best practice: Use withContext for heavy I/O inside suspend functions — keeps UI/main thread free.
4. Cold & Hot Flows – Kotlin’s Reactive Streams
Flow is Kotlin’s built-in way to handle asynchronous streams of data — like RxJava’s Observable but simpler and integrated with coroutines.
Cold Flow → starts emitting data only when collected (lazy, on-demand) Hot Flow → emits data whether collected or not (shared, like live data)
A. Cold Flows (flow { })
Cold flows are created with flow { } — they are suspendable and produce values asynchronously.
Example 4 – Basic cold flow
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import kotlinx.coroutines.flow.* fun coldFlow(): Flow<Int> = flow { for (i in 1..5) { delay(1000) emit(i) // Send value } } fun main() = runBlocking { coldFlow().collect { value -> println("Received: $value") } } |
Output (1 per second):
|
0 1 2 3 4 5 6 7 8 9 10 |
Received: 1 Received: 2 Received: 3 Received: 4 Received: 5 |
Step-by-step:
- flow { … } → creates a cold flow builder
- emit(value) → suspends and sends value to collector
- collect { … } → terminal operator — starts the flow
Cold flow property: If you call coldFlow().collect { } twice → it runs twice independently (cold start each time).
B. Hot Flows – StateFlow & SharedFlow
StateFlow → hot flow that holds a state (latest value) — like LiveData. SharedFlow → hot flow for events (no state, can replay).
StateFlow example:
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import kotlinx.coroutines.flow.* fun main() = runBlocking { val state = MutableStateFlow(0) // Initial state 0 launch { state.collect { value -> println("Received state: $value") } } delay(1000) state.value = 1 // Update state delay(1000) state.value = 2 } |
Output:
|
0 1 2 3 4 5 6 7 8 |
Received state: 0 Received state: 1 Received state: 2 |
SharedFlow example (events with replay):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
val shared = MutableSharedFlow<Int>(replay = 2) // Replay last 2 emissions launch { shared.collect { println("Collector 1: $it") } } shared.emit(1) shared.emit(2) delay(1000) // New collector gets replayed 1 and 2 launch { shared.collect { println("Collector 2: $it") } } shared.emit(3) |
Output:
|
0 1 2 3 4 5 6 7 8 9 10 11 |
Collector 1: 1 Collector 1: 2 Collector 2: 1 // Replayed Collector 2: 2 // Replayed Collector 1: 3 Collector 2: 3 |
5. Flow Operators – map, filter, collect, catch, etc.
Flow has many operators like collections — but they are suspendable and asynchronous.
| Operator | Purpose | Example |
|---|---|---|
| map | Transform each emission | .map { it * 2 } |
| filter | Keep emissions that match | .filter { it > 0 } |
| collect | Terminal – consume emissions | .collect { println(it) } |
| catch | Handle exceptions in upstream | .catch { println(“Error: $it”) } |
| onEach | Side effect on each emission | .onEach { println(“Emitted: $it”) } |
| transform | Transform + emit multiple | .transform { emit(it); emit(it * 2) } |
| flowOn | Change dispatcher upstream | .flowOn(Dispatchers.IO) |
Example 5 – Flow with operators
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
fun numberFlow(): Flow<Int> = flow { for (i in 1..10) { delay(500) emit(i) } } fun main() = runBlocking { numberFlow() .filter { it % 2 == 0 } // Even numbers .map { it * it } // Squares .onEach { println("Processed: $it") } // Side effect .catch { println("Error: $it") } // Handle errors .collect { println("Collected: $it") } // Terminal } |
Output (every 0.5s for even numbers):
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
Processed: 4 Collected: 4 Processed: 16 Collected: 16 Processed: 36 Collected: 36 Processed: 64 Collected: 64 Processed: 100 Collected: 100 |
Step-by-step:
- Flow emits 1 to 10 every 0.5s.
- filter keeps evens.
- map squares them.
- onEach prints processed.
- catch handles any errors (e.g., if emit throws).
- collect consumes and prints final.
flowOn for dispatcher:
|
0 1 2 3 4 5 6 7 8 |
numberFlow() .flowOn(Dispatchers.IO) // Run upstream on IO dispatcher .collect { ... } |
Quick Recap Table (Your Cheat Sheet)
| Concept | Key Points / Example | Benefit |
|---|---|---|
| CoroutineScope | CoroutineScope(Dispatchers.Default) | Group & manage coroutines |
| SupervisorJob | SupervisorJob() | Independent failure handling |
| withContext | withContext(Dispatchers.IO) { … } | Switch dispatcher – non-blocking |
| Cold Flow | flow { emit(1); emit(2) } | Lazy, on-demand, cold start |
| StateFlow | MutableStateFlow(0) | Hot, holds state, shared |
| SharedFlow | MutableSharedFlow(replay = 1) | Hot, events, replay |
| Operators | .map { … }, .filter { … }, .collect { … } | Transform & consume flows |
Common Newbie Mistakes & Fixes
| Mistake | Problem | Fix |
|---|---|---|
| Not waiting for children | Coroutines finish after main exits | Use join() or coroutineScope { } |
| Using normal Job instead of SupervisorJob | One failure cancels all | Use SupervisorJob() for independence |
| Blocking in wrong dispatcher | UI freeze (Android) or thread starvation | Use Dispatchers.IO for blocking I/O |
| Collecting cold flow multiple times | Runs multiple times (cold) | Use hot flow (SharedFlow) for shared |
| Forgetting to import coroutines | Cannot find flow, collect | import kotlinx.coroutines.flow.* |
Homework for You (Let’s Make It Fun!)
- Basic Create a scope with SupervisorJob → launch 3 coroutines → make one throw exception → see others continue.
- Medium Use withContext(Dispatchers.IO) in a suspend function to simulate a 2s network call.
- Advanced Create a cold flow that emits numbers 1 to 10 every 0.5s → use map & filter to get even squares → collect.
- Fun Create a StateFlow for a counter → launch coroutine that updates it every 1s → collect in main.
- Challenge Fix this code:
Kotlin0123456789fun main() = runBlocking {launch { delay(1000); println("Done") }println("Main finished") // Prints before launch done!}
You’ve just mastered structured concurrency & Flow — now you can build reactive, safe, concurrent apps like a pro!
