Lesson 7 of 83 intermediate

Coroutines Fundamentals: suspend, Structured Concurrency & Dispatchers

The #1 most asked Android interview topic — master coroutines cold

Open interactive version (quiz + challenge)

Real-world analogy

A coroutine is like a pauseable recipe. You're baking a cake: you start mixing, then PAUSE while waiting for the oven to preheat (suspend), go do something else, and RESUME when it's ready — all on the same chef (thread), without blocking the kitchen. Dispatchers are the kitchens: Main (presentation), IO (dishwashing), Default (math).

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

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. 1. viewModelScope.launch { } — coroutine is tied to ViewModel lifecycle; cancelled when ViewModel is cleared
  2. 2. _uiState.value = UiState.Loading — runs on Dispatchers.Main (launch inherits Main in ViewModel)
  3. 3. val orders = repository.getWorkOrders(fieldId) — suspend call; coroutine suspends here, thread is free
  4. 4. async { fetchWorkOrders() } and async { fetchStats() } — both start immediately, run in parallel
  5. 5. ordersDeferred.await() — suspends until orders are ready; if stats complete first, both are available immediately
  6. 6. withContext(Dispatchers.IO) { } — switches to IO thread pool for network and database; resumes on caller's dispatcher after block
  7. 7. withContext(Dispatchers.Default) { remote.map { it.toDomain() } } — CPU mapping on Default pool; not blocking IO threads
  8. 8. repeatOnLifecycle(State.STARTED) — pauses collection when app goes to background; resumes on STARTED; prevents wasted work
  9. 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?
Three bugs: wrong scope, wrong thread for UI update, blocking network call on wrong thread
Show answer
Bug 1: GlobalScope not tied to ViewModel lifecycle — causes memory leaks. Fix: viewModelScope.launch { }. Bug 2: UI update (binding.nameText.text) inside a coroutine may run on a background thread — must use withContext(Dispatchers.Main) or the launch will inherit Main in ViewModel context but it's fragile. Better: update _uiState StateFlow and let UI observe it. Bug 3: getUser() is NOT a suspend function but calls execute().body() which is a blocking network call — will throw NetworkOnMainThreadException if called from Main. Fix: make it suspend fun getUser(), use withContext(Dispatchers.IO) { api.getUser(id) }, use Retrofit suspend support.

Explain like I'm 5

Imagine you're a chef (thread) cooking multiple dishes. Old way: you stand at the stove staring at water boiling (blocking thread — wasteful). Coroutine way: you put the water on to boil, go chop vegetables, come back when the water's ready — same chef doing more work. Dispatchers.IO is the kitchen for washing dishes, Dispatchers.Default is the kitchen for complicated recipes, Dispatchers.Main is the dining room where you serve guests.

Fun fact

Kotlin coroutines were inspired by similar constructs in Go (goroutines) and C# (async/await), but Kotlin's implementation uses compile-time state machine transformation rather than runtime scheduling — making them essentially zero-overhead compared to manual callback code. A simple Android app can have thousands of coroutines running simultaneously on just 2-3 threads.

Hands-on challenge

Design a SyncManager for an offline-first field operations app. It must: 1) Use coroutineScope to run parallel sync for WorkOrders, Assets, and Employees simultaneously. 2) Report sync progress via a Flow — emit SyncProgress(stage, percentage) as each completes. 3) Use withContext(Dispatchers.IO) for all network/database calls. 4) Implement retry logic (3 attempts) for each sync operation using a loop with delay(). 5) Ensure full cancellation support — if the scope is cancelled midway, no partial writes occur (use transactions).

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery