Suspending instead of blocking
Imagine a waiter taking an order to the kitchen. A blocking waiter stands at the kitchen door until the food is ready; meanwhile nobody else is served. A suspending waiter hands the order over, serves other tables, and comes back when the kitchen calls.
In code:
- A
suspendfunction can pause (for a network response, a timer, a database query) without blocking the thread. The main thread keeps drawing the UI in the meantime. - A coroutine is a piece of work that can suspend. You start one with
launch. - A scope decides how long coroutines may live.
viewModelScopecancels everything when the ViewModel is cleared;lifecycleScopewhen the screen is destroyed. - A dispatcher decides which threads run the code:
Dispatchers.Mainfor UI,Dispatchers.IOfor blocking input/output,Dispatchers.Defaultfor heavy computation.
Coroutines are suspendable computations scheduled on dispatchers, organized by structured concurrencyStructured concurrencyOrganizing concurrent work so every task has a parent that waits for it, cancels it and receives its errors.Open in glossary →: every coroutine runs in a CoroutineScope, a parent waits for its children, cancellation flows down and failures flow up. suspend functions should be main-safe (callers on Dispatchers.Main must never block), which libraries like Retrofit and Room already are.
Flow is a cold, suspending stream; StateFlow and SharedFlow are hot ones for state and events. The senior topics are the edges of structure: exception propagation and supervisorScope, cooperative cancellation, callbackFlow lifetimes, sharing with stateIn/shareIn, backpressure, and testing with virtual time. All in the Detailed version.
sealed interface OrdersUiState {
data object Loading : OrdersUiState
data class Loaded(val orders: List<Order>) : OrdersUiState
data class Error(val message: String) : OrdersUiState
}
class OrdersViewModel(private val repository: OrdersRepository) : ViewModel() {
private val _state = MutableStateFlow<OrdersUiState>(OrdersUiState.Loading)
val state: StateFlow<OrdersUiState> = _state.asStateFlow()
init { refresh() }
fun refresh() {
viewModelScope.launch { // cancelled when the ViewModel is cleared
_state.value = OrdersUiState.Loading
_state.value = try {
OrdersUiState.Loaded(repository.orders()) // suspends; the main thread stays free
} catch (e: IOException) {
OrdersUiState.Error("Check your connection and try again.")
}
}
}
}
class OrdersRepository(private val api: OrdersApi) {
suspend fun orders(): List<Order> = api.orders() // a Retrofit suspend function: main-safe
}viewModelScope.launch starts a coroutine on the main thread. When it reaches repository.orders(), it suspends: Retrofit does the network work on its own threads, the main thread goes back to drawing, and the coroutine continues on the main thread when the response arrives. If the user leaves and the ViewModel is cleared, the coroutine is cancelled, and so is the request.
Work that outlives its screen
Tap the line with the bug.
class ProfileViewModel(private val api: ProfileApi) : ViewModel() {
private val _profile = MutableStateFlow<Profile?>(null)
val profile: StateFlow<Profile?> = _profile.asStateFlow()
fun load() {
GlobalScope.launch(Dispatchers.Main) {
_profile.value = api.profile()
}
}
}Hint: The user opens the profile and leaves before it loads. Who cancels this coroutine?
GlobalScope lives as long as the whole app process. Nothing cancels its coroutines: when the user leaves, the request keeps running, and the coroutine keeps a reference to the ViewModel (through _profile), so the ViewModel can’t be garbage collected until the request finishes, or forever, if the coroutine loops or waits on a flow. Errors also go nowhere sensible: an exception here crashes the app.
Use the scope that matches the work’s owner. For ViewModel work, that’s viewModelScope, which uses the main dispatcher by default and is cancelled in onCleared():
fun load() {
viewModelScope.launch {
_profile.value = api.profile()
}
}GlobalScope is marked as a delicate API for this reason. If work really must outlive the screen (uploading a photo the user already “sent”), it belongs to an application-level scope you create and inject, or to WorkManager, which survives even the process being killed.
launch doesn’t wait
launch starts a coroutine and returns immediately. The code after it keeps running:
Predict the output
What gets printed, in order?
fun main() = runBlocking {
launch {
delay(100)
println("A")
}
println("B")
delay(200)
println("C")
}launch schedules the new coroutine and returns right away, so B prints first. The launched coroutine runs while the outer one is suspended in delay(200): after 100 ms it prints A, and at 200 ms the outer one continues and prints C. runBlocking (used here so the example runs in a plain main or a test) waits for its children before returning.
To get a result from concurrent work, use async and await():
suspend fun loadDashboard(): Dashboard = coroutineScope {
val profile = async { api.profile() } // both start now
val orders = async { api.orders() }
Dashboard(profile.await(), orders.await()) // wait for both
}coroutineScope waits for both children, and if one fails, cancels the other and rethrows. The two requests take as long as the slower one, not the sum.
Main-safe functions
Many APIs already suspend without blocking (Retrofit, Room, DataStore, Ktor). Code that blocks a thread (reading a file with File.readText(), a legacy SDK call, parsing a huge JSON) must move to another dispatcher:
class NotesRepository(
private val file: File,
private val io: CoroutineDispatcher = Dispatchers.IO, // injected, so tests can replace it
) {
suspend fun load(): List<Note> = withContext(io) {
Json.decodeFromString(file.readText()) // blocking read, now on an IO thread
}
}withContext runs the block on the IO dispatcher and returns to the caller’s dispatcher with the result. The rule: a suspend function must be safe to call from the main thread, so the function that blocks is responsible for switching, not its callers.
Exceptions follow the structure
- In
coroutineScope(and a plainJob), a failing child cancels its siblings and the parent, and the exception is rethrown fromcoroutineScope. That’s what you want for “all or nothing” work. - In
supervisorScope(andSupervisorJob, whichviewModelScopeandlifecycleScopeuse), a failing child doesn’t affect its siblings. Its exception goes to theCoroutineExceptionHandler, and with none installed, to the thread’s uncaught exception handler, which crashes an Android app. launchpropagates failures immediately;asyncholds the exception untilawait(), but in a non-supervisor scope it still cancels the parent at once. Anasyncwhose result is never awaited in a supervisor scope swallows its error.CoroutineExceptionHandleronly works on root coroutines (or direct children of a supervisor); on a nestedlaunchit’s ignored.
Cancellation is cooperative
Cancelling a coroutine sets a flag and throws CancellationException at the next suspension point that checks it. A loop that never suspends (parsing, image processing) runs to the end unless it calls ensureActive() or yield(). Cleanup that must itself suspend after cancellation (sending “upload aborted”) needs withContext(NonCancellable). And any broad catch (e: Exception) must rethrow CancellationException.
One failure, two coroutines
Predict the output
What gets printed?
fun main() = runBlocking {
try {
coroutineScope {
launch {
delay(50)
throw IllegalStateException("boom")
}
launch {
delay(100)
println("B done")
}
}
} catch (e: IllegalStateException) {
println("caught ${e.message}")
}
}Inside coroutineScope, the first child fails after 50 ms. That cancels the scope, which cancels the second child while it's still in delay(100), so B done never prints. coroutineScope then rethrows the original exception, and the catch prints caught boom. With supervisorScope instead, B done would print and the exception would go to the uncaught exception handler: no catch, and on Android, a crash.
- Structured concurrency: parents wait for children, cancellation flows down, failures flow up.
coroutineScopeis all-or-nothing;supervisorScope/SupervisorJobisolate children, and an unhandled failure there crashes an Android app. CoroutineExceptionHandlerworks only on root coroutines; rethrowCancellationExceptionfrom broad catches; long loops needensureActive().
Check this part
What does a suspend function do while waiting for a network response?
Which scope should a ViewModel use for its coroutines?
A child launched in supervisorScope throws and no handler is installed. What happens on Android?
A cancelled coroutine runs a CPU loop with no suspension points. When does it stop?