Kotlin Coroutines Demystified
The Complete Practical Guide Every Kotlin Developer Should Read
When I first started using Coroutines, I thought I understood them.
I could write:
viewModelScope.launch {
val users = repository.getUsers()
updateUi(users)
}
The code worked. The UI was smooth.
Then a production bug appeared. A cancelled screen was still making network calls and a loading spinner never stopped.
That was the moment I realized:
Knowing coroutine syntax is not the same as understanding coroutines.
Coroutines are one of the most powerful features in Kotlin, but they are also one of the most misunderstood. This guide covers the fundamentals, internals, best practices, pitfalls, and real-world usage.
Why Coroutines Exist
Before Coroutines, asynchronous programming often involved:
- Threads
- Executors
- Futures
- Callbacks
- CompletableFutures
As projects grew, callback chains became difficult to maintain.
Coroutines were introduced to provide asynchronous programming with code that reads like synchronous code.
What Is a Coroutine?
A common definition is:
Coroutines are lightweight threads.
A more accurate definition is:
A coroutine is a unit of work that can suspend and resume without blocking a thread.
Example:
suspend fun fetchUser() {
delay(1000)
}
delay() suspends the coroutine and frees the thread for other work.
How Coroutines Work Under the Hood
The Kotlin compiler converts suspend functions into state machines.
When suspension occurs:
- Current state is saved.
- Execution pauses.
- Thread becomes available.
- Coroutine resumes later from the saved state.
This is the primary reason coroutines scale efficiently.
suspend Does Not Start a Coroutine
suspend fun getUser()
A suspend function only indicates that suspension may occur.
Coroutines are started using builders such as:
launch {}
async {}
runBlocking {}
launch vs async
launch
Use when no result is required.
launch {
syncUsers()
}
Returns:
Job
async
Use when a result is needed.
val user = async {
getUser()
}
user.await()
Returns:
Deferred<T>
Parallel Execution Example
coroutineScope {
val user = async { getUser() }
val posts = async { getPosts() }
user.await()
posts.await()
}
A common use case is loading profile data and dashboard data together.
CoroutineContext
Every coroutine contains context information.
launch(
Dispatchers.IO +
CoroutineName("UserSync")
) {
}
Typically includes:
- Dispatcher
- Job
- CoroutineName
- CoroutineExceptionHandler
Dispatchers Explained
Dispatchers.Main
For UI updates.
withContext(Dispatchers.Main) {
renderUi()
}
Dispatchers.IO
For network, file and database operations.
withContext(Dispatchers.IO) {
api.getUsers()
}
Dispatchers.Default
For CPU-intensive operations.
withContext(Dispatchers.Default) {
calculateReport()
}
Tricky Point
Don't run CPU-heavy work on IO.
Rule:
Waiting -> IO
Computing -> Default
Structured Concurrency
One of the biggest advantages of coroutines.
coroutineScope {
launch { fetchUsers() }
launch { fetchOrders() }
}
Benefits:
- Children belong to a parent scope.
- Parent waits for children.
- Failures propagate predictably.
- Reduces leaks.
GlobalScope: A Common Mistake
Avoid:
GlobalScope.launch {
syncUsers()
}
Problems:
- No lifecycle awareness
- Hard cancellation
- Debugging issues
- Memory leak risk
Use:
viewModelScope
lifecycleScope
Cancellation Is Cooperative
Bad:
while(true) {
}
Good:
while(isActive) {
}
Another good practice:
ensureActive()
Blocking vs Suspending
Bad:
Thread.sleep(5000)
Good:
delay(5000)
Thread.sleep() blocks threads.
delay() suspends coroutines.
Exception Handling
val handler = CoroutineExceptionHandler { _, throwable ->
println(throwable.message)
}
Use it for top-level coroutine exception handling.
SupervisorJob
Scenario:
- Load profile
- Load notifications
- Load messages
If notifications fail, profile should still load.
val scope = CoroutineScope(
SupervisorJob() + Dispatchers.IO
)
This isolates failures.
Flow
Flow is designed for streams of values.
flow {
emit(1)
emit(2)
emit(3)
}
Collect values:
flow.collect {
println(it)
}
StateFlow vs SharedFlow
StateFlow
Best for UI state.
MutableStateFlow(UiState())
Examples:
- Loading
- Success
- Error
SharedFlow
Best for one-time events.
MutableSharedFlow<Event>()
Examples:
- Toast
- Navigation
- Dialog events
Tricky Point
Avoid using StateFlow for navigation events because they can be replayed after configuration changes.
Useful Flow Operators
map
flow.map { it.name }
filter
flow.filter { it.isActive }
debounce
searchQuery.debounce(500)
Useful for search APIs.
combine
combine(userFlow, settingsFlow) { user, settings ->
Ui(user, settings)
}
Flow vs Channel
Flow
Best for stream processing.
Flow<User>
Channel
Best for producer-consumer communication.
Channel<Task>()
Most Android use cases need Flow more often than Channel.
Race Conditions Still Exist
Bad:
var count = 0
repeat(1000) {
launch {
count++
}
}
Use:
Mutex
AtomicInteger
for shared mutable state.
Quick Decision Guide
| Requirement | Recommended Tool |
|---|---|
| Background task | launch |
| Need a result | async |
| Thread switch | withContext |
| UI state | StateFlow |
| One-time event | SharedFlow |
| Data stream | Flow |
| Parallel execution | async + await |
| Failure isolation | SupervisorJob |
| Android lifecycle work | viewModelScope |
Final Thoughts
Coroutines are not just about writing asynchronous code.
They're about writing asynchronous code that remains readable, maintainable, efficient, and safe.
Once you understand:
- Suspension vs Blocking
- Dispatchers
- CoroutineContext
- Structured Concurrency
- Cancellation
- Exception Propagation
- Flow APIs
- StateFlow vs SharedFlow
Coroutines stop feeling magical and start feeling predictable.
And predictable systems are what great software is built on.
Happy Coding! 🚀
