Concurrency, Coroutines & Flow (Deep Dive)
Master asynchronous programming, concurrency, Kotlin Coroutines, builders, dispatchers, exception handling, and cold/hot flows.
Module 11: Concurrency, Coroutines & Flow (Deep Dive)
Learning Objectives
By the end of this module, you’ll understand:
- Why Android has a Main Thread
- Event Loop & Message Queue
- ANR (Application Not Responding)
- Threads
- Problems with raw threads
- Kotlin Coroutines
- Suspend functions
- Dispatchers
- CoroutineScope
- Structured Concurrency
- launch vs async
- withContext
- Exception handling
- Cancellation
- Flow
- StateFlow
- SharedFlow
- Best practices
Part 1 — Why Android Has a Main Thread
Let’s start with a simple question.
When you tap a button:
User Touches Screen
↓
Button Click
↓
Text Changes
Who processes that touch?
The answer:
The Main Thread (also called the UI Thread).
Android has one special thread responsible for:
- Drawing the UI
- Processing touch events
- Animations
- Lifecycle callbacks
- View updates
- Compose recomposition scheduling
Everything visual happens here.
1. Why Only One UI Thread?
Imagine two threads changing the same TextView.
Thread A:
Text = "Hello"
Thread B:
Text = "World"
Both execute simultaneously.
Possible results:
Hello
World
Heorld
Crash
Race conditions.
To avoid this:
Android says:
Only the Main Thread may update the UI.
This greatly simplifies UI consistency.
2. The Event Loop
The Main Thread doesn’t sit idle.
Internally it continuously runs something conceptually like:
while (true) {
Take next message
Execute it
}
This is the event loop.
Messages include:
- Touch events
- Draw requests
- Lifecycle callbacks
- Network callback results posted back to the UI
- Runnable objects
3. Message Queue
Imagine people waiting in line.
Queue
↓
Touch Event
↓
Button Click
↓
Redraw
↓
Animation
The Main Thread processes one message at a time.
Not two.
One.
This ordering is why the UI behaves predictably.
4. What Happens During a Button Click?
User Tap
↓
Touch Event
↓
Message Queue
↓
Main Thread
↓
Click Listener
↓
Your Code
Notice:
Your click listener runs on the Main Thread by default.
Part 2 — The Biggest Problem
Suppose you do this:
button.setOnClickListener {
Thread.sleep(10000)
}
For 10 seconds:
The Main Thread is sleeping.
What happens?
No Drawing
No Touch
No Animation
Frozen UI
The app appears hung.
5. ANR (Application Not Responding)
If the Main Thread remains blocked for too long (for example, around 5 seconds while processing input), Android may display:
Application Isn't Responding
The user can:
- Wait
- Close the app
ANRs are one of the most serious UX problems in Android.
6. Heavy Work Examples
Never perform directly on the Main Thread:
❌ Network requests
❌ Database queries
❌ Image decoding
❌ File I/O
❌ Large JSON parsing
❌ Cryptography
❌ Long-running loops
These should run on background threads.
Part 3 — Threads
The traditional solution:
Thread {
}.start()
Now the work runs separately.
Good.
But threads introduce new problems.
Problems with Threads
Imagine:
100 API calls.
Create:
100 Threads.
Each thread consumes:
- Memory
- Stack space
- Scheduling overhead
Too many threads hurt performance.
Another issue:
How do you cancel them?
How do you coordinate them?
How do you wait for results?
How do you propagate errors?
Managing threads manually quickly becomes difficult.
Part 4 — Coroutines
Google recommends Kotlin Coroutines.
Important:
A coroutine is NOT a thread.
Many developers believe:
Coroutine = Thread
Incorrect.
A coroutine is a lightweight unit of work that can be suspended and resumed.
Think of it like a task that uses threads when needed.
Imagine a restaurant.
Thread:
One waiter.
Coroutine:
One customer order.
One waiter can manage multiple orders over time.
Similarly:
One thread can execute many coroutines.
Coroutine Example
viewModelScope.launch {
val users = repository.getUsers()
}
Only one coroutine was created.
Not necessarily one thread.
Part 5 — Suspend Functions
This keyword confuses almost everyone initially.
suspend fun loadUsers()
Many think:
“suspend” means asynchronous.
Wrong.
It means:
This function may suspend its execution without blocking the underlying thread.
Imagine reading a book.
Phone rings.
You place a bookmark.
Take the call.
Return later.
Continue reading.
That’s suspension.
The thread is free to do other work while the coroutine waits.
6. How Suspension Works
Suppose:
Coroutine
↓
API Request
↓
Waiting...
Traditional thread:
Thread
↓
Blocked
↓
Idle
Coroutine:
Coroutine Suspended
↓
Thread Released
↓
Thread Does Other Work
↓
API Returns
↓
Coroutine Resumes
Much more efficient.
Part 6 — Dispatchers
Dispatchers decide where a coroutine executes.
Dispatchers.Main
Runs on:
Main Thread
Used for:
- UI updates
- Compose state updates
- LiveData / StateFlow interactions
Dispatchers.IO
Optimized for:
Database
Files
Network
Large pool of threads for blocking I/O.
Dispatchers.Default
Optimized for:
CPU-intensive work.
Examples:
- Image processing
- Sorting
- Compression
- Large calculations
Dispatchers.Unconfined
Rarely needed.
Mostly for advanced scenarios and testing.
Avoid unless you understand its behavior.
7. withContext()
Suppose:
Main Thread
↓
Need Database
Instead of creating a new coroutine:
withContext(Dispatchers.IO) {
}
Flow:
Main
↓
IO
↓
Database
↓
Main
The coroutine switches context, performs the work, then resumes.
This is the preferred way to move between dispatchers.
Part 7 — Coroutine Builders
launch
Fire-and-forget.
launch {
}
Returns a Job.
Good for:
- Updating UI state
- Background work where no value is returned
async
Returns a result.
async {
}
Produces a Deferred<T>.
Later:
await()
to retrieve the value.
Example
Need two APIs.
Sequential:
Users
↓
Wait
↓
Orders
Parallel:
Users
Orders
↓
Wait for Both
Using async can improve performance when the tasks are independent.
Part 8 — Structured Concurrency
One of the greatest strengths of coroutines.
Imagine:
Parent Coroutine
↓
Child A
↓
Child B
↓
Child C
If the parent is cancelled:
All children are cancelled automatically.
This prevents “orphaned” background work.
Example:
User leaves screen.
Activity destroyed.
ViewModel cleared.
viewModelScope is cancelled.
Every child coroutine launched in that scope is cancelled.
No memory leaks.
No wasted work.
9. Coroutine Scopes
A coroutine must belong to a scope.
viewModelScope
Lives as long as the ViewModel.
Perfect for:
- API calls
- Database operations
- Business logic
lifecycleScope
Lives as long as the LifecycleOwner (Activity/Fragment).
Useful for UI-related coroutines tied to lifecycle events.
GlobalScope
Avoid in application code.
Why?
It ignores lifecycle.
Coroutines may continue running long after the UI disappears.
Part 9 — Exception Handling
Suppose:
API
↓
IOException
Without handling:
Coroutine fails.
You should use:
try/catchfor expected failures.CoroutineExceptionHandlerfor uncaught exceptions in root coroutines.supervisorScopeorSupervisorJobwhen you don’t want one child failure to cancel its siblings.
Part 10 — Flow
Flow represents a stream of values over time.
Imagine:
Temperature sensor.
22°
↓
23°
↓
24°
↓
25°
Not one value.
Many values.
That’s Flow.
Example:
flow {
emit(1)
emit(2)
emit(3)
}
The consumer receives values one by one.
Cold Flow
A Flow does nothing until collected.
Imagine Netflix.
Movie exists.
Nothing happens until you press Play.
Flow behaves similarly.
Flow
↓
No Collector
↓
No Execution
Every new collector starts a fresh execution.
Collecting
flow.collect {
}
Flow:
Emit
↓
Collect
↓
Emit
↓
Collect
Part 11 — StateFlow
Think of StateFlow as:
A state container that always has a current value.
Example:
Loading
↓
Success
↓
Error
The latest state is always available.
Unlike a cold Flow, StateFlow is hot—it exists independently of collectors.
Example:
MutableStateFlow(
UiState.Loading
)
Compose and Activities/Fragments observe it and update automatically.
Part 12 — SharedFlow
Some events should not be replayed as persistent state.
Examples:
Show Toast
Navigate
Snackbar
These are events.
Not state.
SharedFlow is suitable for broadcasting such events.
It can also be configured to replay recent emissions when appropriate.
Flow Comparison
| Feature | Flow | StateFlow | SharedFlow |
|---|---|---|---|
| Cold/Hot | Cold | Hot | Hot |
| Current value | ❌ | ✅ | Optional replay |
| State holder | ❌ | ✅ | ❌ |
| Multiple collectors | ✅ | ✅ | ✅ |
| Common use | Streams | UI State | Events |
Part 13 — Real Android Example
User opens Home Screen.
HomeScreen
↓
HomeViewModel
↓
Repository
↓
Flow<List<Product>>
↓
StateFlow<HomeUiState>
↓
Compose UI
When products change:
Database
↓
Flow Emits
↓
Repository
↓
ViewModel
↓
State Updated
↓
Compose Recomposes
No manual refresh required.
Common Mistakes
❌ Launching long work on Dispatchers.Main
This blocks the UI and can lead to ANRs.
❌ Using GlobalScope
Coroutines outlive the screen and can leak work.
❌ Confusing suspend with background execution
A suspend function can still run on the Main dispatcher unless you explicitly switch context.
❌ Using StateFlow for one-time events
Navigation or Toast events may be re-delivered after configuration changes.
Use SharedFlow (or another event mechanism) instead.
❌ Collecting a Flow without respecting lifecycle
In Activities and Fragments, use lifecycle-aware collection (for example, repeatOnLifecycle) so collection stops when the UI is not visible.
Mental Model
Imagine a railway system.
Main Thread
│
Station (Message Queue)
│
Coroutines (Passengers)
│
Dispatchers (Tracks)
│
Flow (Train carrying values)
- The Main Thread is the central station.
- Coroutines are passengers traveling through the system.
- Dispatchers decide which track they use.
- Flow is a train delivering values over time.
- Structured concurrency ensures that when the station closes (scope is cancelled), all associated journeys end safely.
Best Practices
- Keep the Main Thread free of expensive work.
- Use
viewModelScopefor ViewModel tasks. - Switch to
Dispatchers.IOfor blocking I/O. - Prefer
withContext()over manually creating threads. - Model screen state with
StateFlow. - Model one-time UI events with
SharedFlow. - Collect Flows using lifecycle-aware APIs.
- Let cancellation propagate naturally through structured concurrency.
Interview Questions
- Why does Android have a single UI thread?
- What causes an ANR?
- What’s the difference between a thread and a coroutine?
- What does the
suspendkeyword actually mean? - Compare
launchandasync. - When would you use
Dispatchers.IOvsDispatchers.Default? - What is structured concurrency, and why is it important?
- Explain the difference between Flow, StateFlow, and SharedFlow.
- Why is Flow considered “cold”?
- Why should
GlobalScopegenerally be avoided?
Module 11 Summary
You now understand the execution model behind modern Android apps:
- The Main Thread handles all UI work.
- Blocking the Main Thread leads to frozen interfaces and potentially ANRs.
- Coroutines provide lightweight, structured concurrency without requiring one thread per task.
- Dispatchers determine where work executes.
- Structured concurrency automatically manages cancellation and lifecycle.
- Flow models asynchronous streams of data.
- StateFlow is ideal for observable UI state.
- SharedFlow is well suited for events.
At this stage, you have the architectural and concurrency foundations used in production Android applications.
Next Module: Dependency Injection (Hilt & Dagger)
In the next module, we’ll go beyond the conceptual overview from Module 10 and dive into Dependency Injection in detail.
We’ll explore:
- What a dependency really is.
- Why
new/constructors alone don’t scale. - Inversion of Control (IoC).
- Dependency Injection patterns (constructor, field, method).
- Why Dagger was created.
- How Hilt builds on Dagger.
- Code generation and dependency graphs.
- Scopes (
Singleton,ActivityRetained,ViewModel, etc.). - Modules, Providers, and Bindings.
- Injecting Retrofit, Room, Repositories, and ViewModels.
- Common DI mistakes and debugging dependency graphs.
This module is essential for understanding how large Android applications wire together dozens or hundreds of collaborating classes without becoming tightly coupled.