What you'll learn
Fetching data, reading a database, saving a file — this work takes time, and if you do it on the UI thread the app freezes. Kotlin coroutines are how Android does asynchronous work cleanly. You know the basics from Kotlin; here you'll learn the Android-specific pieces: viewModelScope, dispatchers, and modeling loading and error states.
By the end you'll be able to:
- Explain why the main thread must never be blocked
- Launch coroutines safely from a ViewModel with
viewModelScope - Move heavy work to the right
Dispatcher - Model loading and error states around async calls
Never block the main thread
Android runs your UI on a single main thread (the UI thread). It draws every frame and handles every tap. If you run slow work there — a network request, a big database query — the thread can't draw, the UI freezes, and after a few seconds the system shows the dreaded "Application Not Responding" dialog. The rule is absolute: keep slow work off the main thread.
Key idea
suspend function can pause without blocking its thread, freeing the main thread to keep the UI smooth while the work happens elsewhere.suspend & viewModelScope
A suspend function can only be called from a coroutine or another suspend function. To start one, you need a scope. In a ViewModel, use the built-in viewModelScope: coroutines launched in it are automatically cancelled when the ViewModel is cleared, so you never leak work from a screen the user has left:
class NewsViewModel(
private val repository: NewsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(NewsUiState())
val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
fun loadHeadlines() {
viewModelScope.launch { // a coroutine tied to this ViewModel
_uiState.update { it.copy(isLoading = true) }
val articles = repository.fetchHeadlines() // suspends, doesn't block
_uiState.update { it.copy(isLoading = false, articles = articles) }
}
}
}Watch out
viewModelScope (or a Compose LaunchedEffect, covered in Phase 5). Never use GlobalScope — its coroutines outlive your screen and leak memory and work.Dispatchers
A dispatcher decides which thread (pool) a coroutine runs on. Choose the right one for the work:
| Dispatcher | Use for |
|---|---|
Dispatchers.Main | Touching the UI / Compose state (the default in viewModelScope) |
Dispatchers.IO | Network, database, file — anything that waits on I/O |
Dispatchers.Default | CPU-heavy work — parsing, sorting, image processing |
Switch with withContext(...). Best practice: keep the heavy work encapsulated in the repository so the ViewModel doesn't worry about threads — it just calls a suspend function:
suspend fun fetchHeadlines(): List<Article> =
withContext(Dispatchers.IO) { // switch to a background thread pool
api.getHeadlines() // network / disk work goes here
}Tip
suspend functions. You'll mostly reach for withContext(Dispatchers.IO) only around your own blocking code.Loading & error states
Async work has three outcomes — in progress, success, failure — and a good UI shows all three. Set isLoading before the call, clear it after, and wrap the call in try/catch to turn failures into a friendly message rather than a crash:
fun loadHeadlines() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val articles = repository.fetchHeadlines()
_uiState.update { it.copy(isLoading = false, articles = articles) }
} catch (e: IOException) {
_uiState.update { it.copy(isLoading = false, error = "No connection") }
}
}
}The View reads this one UI state and renders a spinner, the articles, or a retry message — the pattern from Module 17 paying off. Structured concurrency ties it together: cancel the scope (leave the screen) and every child coroutine stops too, automatically.
Recap & quick check
Key takeaways
- Never run slow work on the main thread — it freezes the UI and triggers ANR.
- suspend functions pause without blocking; launch them from a scope like viewModelScope.
- viewModelScope cancels its coroutines when the ViewModel is cleared — no leaks. Avoid GlobalScope.
- Pick a Dispatcher: Main for UI, IO for network/database/files, Default for CPU-heavy work.
- Model loading and error states with try/catch and your UI state so every outcome is shown.
Quick check
1. Why must you keep slow work off the main thread?
2. Where should you launch coroutines in a ViewModel?
3. Which dispatcher fits a network request?
4. What's wrong with GlobalScope for screen work?
5. How do you handle a failed network call gracefully?
Coroutines handle one-shot async work. Next: streaming data that changes over time, with Kotlin Flow.