Kotlin Coroutines: From Basics to Advanced Patterns
Kotlin Coroutines from basics to advanced: suspend functions, launch vs async, dispatchers, structured concurrency, cancellation, error handling and Flow.
Kotlin Coroutines are the standard way to do asynchronous work on Android — network calls, database queries, and anything that would otherwise freeze the UI. This guide takes you from the basics (suspend, launch, async) to advanced patterns used in production apps: dispatchers, structured concurrency, cancellation, error handling, and Kotlin Flow.
Still learning Kotlin? Start with Kotlin Basics for Android Beginners.
What Are Coroutines?
A coroutine is a piece of work that can pause and resume without blocking a thread. Coroutines are often called "lightweight threads": you can run thousands of them on a handful of threads.
The big win is readability. Asynchronous code looks like normal, top-to-bottom code — no nested callbacks:
viewModelScope.launch {
val user = userRepository.getUser() // suspends, doesn't block the main thread
_uiState.value = UiState.Success(user)
}
Suspend Functions
A suspend function can pause its coroutine while it waits (for the network, the disk, a delay) and resume later on the right thread.
suspend fun fetchUser(id: String): User {
delay(1000) // simulated network wait, non-blocking
return User(id, "Shakil")
}
Suspend functions can only be called from another suspend function or from a coroutine builder like launch.
launch vs async
launchstarts a coroutine that does work and doesn't return a result ("fire and forget").asyncstarts a coroutine that returns a result through aDeferred; you get it withawait().
Use async to run independent tasks in parallel:
suspend fun loadUserData(userId: String): UserData = coroutineScope {
val profile = async { loadProfile(userId) }
val posts = async { loadPosts(userId) }
UserData(
profile = profile.await(),
posts = posts.await()
)
}
Both requests run at the same time, so the total time is the slower of the two, not the sum.
Dispatchers: Choosing the Right Thread
A dispatcher decides which thread a coroutine runs on:
| Dispatcher | Use for |
|---|---|
Dispatchers.Main | UI updates (the default in viewModelScope and lifecycleScope) |
Dispatchers.IO | Network, disk, database |
Dispatchers.Default | CPU-heavy work: sorting large lists, parsing, image processing |
Switch with withContext, ideally inside the repository so callers never have to think about it (this is called being main-safe):
class ReportRepository(
private val api: ReportApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun getReport(): Report = withContext(ioDispatcher) {
api.fetchReport()
}
}
Injecting the dispatcher makes the class easy to test with a test dispatcher.
Scopes and Structured Concurrency
Every coroutine runs in a scope. When the scope is cancelled, all its coroutines are cancelled too. This is structured concurrency, and it prevents leaks.
On Android you'll mostly use:
viewModelScope– cancelled when the ViewModel is cleared.lifecycleScope– cancelled when the Activity/Fragment is destroyed.coroutineScope { }– creates a child scope inside a suspend function; it waits for all children and fails if any child fails.supervisorScope { }– likecoroutineScope, but one child failing doesn't cancel the others.
Avoid GlobalScope. Its coroutines are not tied to any lifecycle and easily leak.
To collect flows safely in an Activity or Fragment, use repeatOnLifecycle:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { render(it) }
}
}
In Compose, collectAsStateWithLifecycle() does the same thing for you — see Jetpack Compose: State Management Best Practices.
Cancellation
Coroutine cancellation is cooperative: a coroutine must check for it. All suspending functions from kotlinx.coroutines (delay, withContext, etc.) check automatically. For long CPU loops, check yourself:
suspend fun processItems(items: List<Item>) = withContext(Dispatchers.Default) {
for (item in items) {
ensureActive() // throws if the coroutine was cancelled
process(item)
}
}
Error Handling
Use try/catch around suspending calls, just like normal code:
viewModelScope.launch {
try {
val result = repository.loadData()
_uiState.value = UiState.Success(result)
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: IOException) {
_uiState.value = UiState.Error("Check your internet connection")
}
}
Important rules:
- Always rethrow
CancellationException. Catching it (for example with a genericcatch (e: Exception)orrunCatching) can keep a cancelled coroutine running. - Exceptions in
launchpropagate to the parent and cancel siblings. Exceptions inasyncare thrown when you callawait(). - Use
supervisorScopewhen independent tasks should fail independently. - A
CoroutineExceptionHandleron a root scope is a last-resort logger, not a substitute for handling errors.
Kotlin Flow for Streams of Data
A suspend function returns one value. A Flow emits many values over time — perfect for database updates, search-as-you-type, or live data.
class MessageRepository(private val dao: MessageDao) {
fun observeMessages(): Flow<List<Message>> = dao.observeAll() // Room emits on every change
}
Transform flows with operators:
val unreadCount: Flow<Int> = repository.observeMessages()
.map { messages -> messages.count { !it.isRead } }
.distinctUntilChanged()
Flows are cold: nothing runs until someone collects. To share one flow with the UI, turn it into a StateFlow in the ViewModel with stateIn:
val uiState: StateFlow<UiState> = repository.observeMessages()
.map { UiState.Success(it) as UiState }
.catch { emit(UiState.Error(it.message ?: "Error")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading
)
WhileSubscribed(5_000) keeps the upstream alive for 5 seconds after the UI stops collecting, so a screen rotation doesn't restart the work.
Testing Coroutines
Use runTest from kotlinx-coroutines-test. It skips delays automatically, so tests run instantly:
@Test
fun `loads user`() = runTest {
val repo = UserRepository(FakeApi(), StandardTestDispatcher(testScheduler))
assertEquals("Shakil", repo.getUser("1").name)
}
Best Practices
- Make suspend functions main-safe by switching dispatchers inside them.
- Inject dispatchers instead of hardcoding
Dispatchers.IO. - Use
viewModelScope/lifecycleScope; avoidGlobalScope. - Never swallow
CancellationException. - Prefer Flow and StateFlow over LiveData in new code.
- Collect flows with
repeatOnLifecycleorcollectAsStateWithLifecycle. - Expose immutable
StateFlow/Flowfrom ViewModels; keep the mutable version private.
Frequently Asked Questions
Are coroutines threads? No. Coroutines run on threads, but many coroutines can share one thread because they suspend instead of blocking.
What's the difference between Flow, StateFlow and SharedFlow?
Flow is cold and emits values to each collector. StateFlow is hot, always has a current value, and is ideal for UI state. SharedFlow is hot and can broadcast values to many collectors without holding a "current" state.
Should I still use LiveData? It still works, but for new code StateFlow is the recommended choice: it's Kotlin-first, works outside Android, and supports all Flow operators.
Conclusion
Coroutines make asynchronous Android code readable, safe and testable. Master suspend, scopes, dispatchers, cancellation and Flow, and you'll write apps that stay fast and responsive. To see how coroutines fit into a full app structure, read Building Clean Architecture in Android.