Coroutines Fundamentals: suspend, Structured Concurrency & Dispatchers
The #1 most asked Android interview topic — master coroutines cold
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Coroutines are Kotlin's solution to asynchronous programming — replacing callbacks and RxJava with sequential-looking code that suspends (pauses) without blocking threads. Structured concurrency ensures coroutines have defined lifetimes tied to scopes, preventing leaks. Dispatchers control which threads coroutines run on. This is the most frequently tested Android interview topic in 2025-2026.
Real-world relevance
In an offline-first field operations enterprise app, a SyncManager uses coroutineScope to launch parallel sync operations for WorkOrders, Employees, and Assets. withContext(Dispatchers.IO) fetches from the network, withContext(Dispatchers.Default) processes and transforms the data, then viewModelScope updates the UI. lifecycleScope.launchWhenStarted collects sync progress Flow for the status bar.
Key points
- What coroutines ARE (not threads) — Coroutines are lightweight, cooperative concurrency primitives — NOT threads. Thousands of coroutines run on a handful of threads. A suspended coroutine releases its thread (doesn't block it), which can then run other coroutines. This is why coroutines scale better than threads for I/O-heavy Android apps.
- suspend keyword — A suspend function can pause execution without blocking the thread. It can only be called from another suspend function or a coroutine builder. The Kotlin compiler transforms suspend functions into state machines under the hood — a Continuation object tracks where to resume.
- Coroutine builders: launch vs async — launch { } starts a coroutine that runs concurrently and returns a Job (fire-and-forget). async { } starts a coroutine that computes a result and returns Deferred — call .await() to get the result. Use launch for side effects, async for parallel value computation.
- withContext — withContext(Dispatchers.IO) { } switches the coroutine to a different dispatcher for the block, then switches back. Does NOT create a new coroutine — it's a suspend function. Use for thread switching within a single coroutine (e.g., run database query on IO, then update UI on Main).
- Dispatchers — Main: Android main thread — UI updates, ViewModel operations. IO: Thread pool optimized for blocking I/O (network, disk) — 64 threads by default. Default: CPU-bound work (JSON parsing, sorting, computation) — number of CPU cores threads. Unconfined: not confined to any thread — rare, used in testing.
- CoroutineScope — Every coroutine runs in a CoroutineScope. The scope defines the lifetime: if the scope is cancelled, all coroutines in it are cancelled. viewModelScope is tied to ViewModel lifecycle. lifecycleScope is tied to Activity/Fragment lifecycle. Prevents memory leaks from leaked coroutines.
- Structured concurrency — The core principle: parent coroutines wait for all children to complete. If a child fails, the parent is notified and cancels other children. If the parent is cancelled, all children are cancelled. This creates a predictable, leak-free tree of coroutines. The opposite is 'fire-and-forget' which is an anti-pattern.
- viewModelScope vs lifecycleScope — viewModelScope: cancelled when ViewModel is cleared (outlives Activity recreation). Use for data operations. lifecycleScope: cancelled when lifecycle is destroyed. Use for UI-driven coroutines. repeatOnLifecycle: suspends and relaunches on lifecycle state changes — the correct way to collect Flow in UI.
- Parallel decomposition with async/await — val userDeferred = async { fetchUser(id) }; val ordersDeferred = async { fetchOrders(id) }; val user = userDeferred.await(); val orders = ordersDeferred.await() — both requests run in parallel. If either fails, the exception propagates and the other is cancelled.
- coroutineScope builder — coroutineScope { } creates a new scope that waits for all child coroutines to complete before returning. If any child fails, all others are cancelled and the exception propagates. Use for parallel work that must all succeed together.
- Continuation and the state machine — The compiler transforms every suspend function into a class implementing Continuation. Each suspension point becomes a state in a when expression. At resume(), the when dispatches to the correct state. This is why suspend functions have zero overhead compared to callbacks — it's compile-time transformation, not runtime magic.
- GlobalScope is an anti-pattern — GlobalScope.launch { } creates coroutines not tied to any lifecycle — they run until the app process dies, cannot be cancelled by scope, and cause memory leaks. Use viewModelScope, lifecycleScope, or a custom CoroutineScope with proper lifecycle management instead.
Code example
// ViewModel using viewModelScope — lifecycle-safe
class WorkOrderViewModel(
private val repository: WorkOrderRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<List<WorkOrder>>>(UiState.Loading)
val uiState: StateFlow<UiState<List<WorkOrder>>> = _uiState.asStateFlow()
fun loadWorkOrders(fieldId: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
val orders = repository.getWorkOrders(fieldId)
_uiState.value = UiState.Success(orders)
} catch (e: AppException) {
_uiState.value = UiState.Error(e.message ?: "Load failed")
}
}
}
// Parallel fetch with async/await
fun loadDashboard(userId: String) {
viewModelScope.launch {
val ordersDeferred = async { repository.getWorkOrders(userId) }
val statsDeferred = async { repository.getStats(userId) }
// Both requests run in parallel
val orders = ordersDeferred.await()
val stats = statsDeferred.await()
_uiState.value = UiState.Success(DashboardData(orders, stats))
}
}
}
// Repository — withContext for thread switching
class WorkOrderRepository(
private val api: WorkOrderApi,
private val dao: WorkOrderDao
) {
suspend fun getWorkOrders(fieldId: String): List<WorkOrder> {
return withContext(Dispatchers.IO) {
val remote = api.fetchWorkOrders(fieldId) // network on IO thread
val mapped = withContext(Dispatchers.Default) {
remote.map { it.toDomain() } // CPU work on Default
}
dao.insertAll(mapped) // DB write on IO thread
mapped
}
}
}
// UI layer — collect with repeatOnLifecycle (correct pattern)
class WorkOrderFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
renderState(state)
}
}
}
}
}Line-by-line walkthrough
- 1. viewModelScope.launch { } — coroutine is tied to ViewModel lifecycle; cancelled when ViewModel is cleared
- 2. _uiState.value = UiState.Loading — runs on Dispatchers.Main (launch inherits Main in ViewModel)
- 3. val orders = repository.getWorkOrders(fieldId) — suspend call; coroutine suspends here, thread is free
- 4. async { fetchWorkOrders() } and async { fetchStats() } — both start immediately, run in parallel
- 5. ordersDeferred.await() — suspends until orders are ready; if stats complete first, both are available immediately
- 6. withContext(Dispatchers.IO) { } — switches to IO thread pool for network and database; resumes on caller's dispatcher after block
- 7. withContext(Dispatchers.Default) { remote.map { it.toDomain() } } — CPU mapping on Default pool; not blocking IO threads
- 8. repeatOnLifecycle(State.STARTED) — pauses collection when app goes to background; resumes on STARTED; prevents wasted work
- 9. viewModel.uiState.collect { state -> renderState(state) } — terminal operator; runs for every emission from StateFlow
Spot the bug
class UserViewModel : ViewModel() {
fun loadUser(id: String) {
GlobalScope.launch { // bug 1
val user = repository.getUser(id)
binding.nameText.text = user.name // bug 2
}
}
}
class UserRepository {
fun getUser(id: String): User { // bug 3
return api.getUser(id).execute().body()!!
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Kotlin Coroutines Guide (kotlinlang.org)
- Android Coroutines Best Practices (developer.android.com)
- CoroutineScope and Structured Concurrency (kotlinlang.org)
- repeatOnLifecycle API Guide (developer.android.com)
- viewModelScope and lifecycleScope (developer.android.com)