Coroutines on Android
A coroutine is not a thread. It is a state machine the compiler generates, dispatched onto a thread. That distinction explains cancellation, structured concurrency, and why suspension is cheap.
The sentence that resolves most confusion about coroutines is this: a suspend function does not run somewhere else. It runs on whatever dispatcher started it, and it pauses — not blocks — when it suspends.
TL;DR#
- A suspend function compiles into a state machine that can return early with a continuation.1 Suspension costs an object allocation, not a thread.
- A dispatcher decides which thread a coroutine resumes on.
Main,DefaultandIOare the three you need.2 - Structured concurrency: a coroutine started in a scope is cancelled with that scope, and a parent waits for its children.
viewModelScopeis cancelled when the clears;lifecycleScopewhen the lifecycle is destroyed. Use them instead of hand-managed jobs.- Exceptions propagate to the caller;
CancellationExceptionis not a failure and must not be swallowed.
Mental model#
Think of a conversation with a supplier.
You call, ask a question, and they say "let me check, I'll call you back". You hang up and take the next call. When they ring back, the conversation continues where it left off, with your notes still on the desk.
The call is a coroutine. The "let me check" is a suspension point. The notes are the continuation. The phone line is a dispatcher — which line they ring back on determines which desk the conversation resumes at.
Where the analogy breaks#
A real conversation can only be resumed by one supplier. A coroutine can be resumed on any thread
in its dispatcher's pool, and the runtime does not guarantee it is the same thread that
suspended. That is why code after a suspension point must re-establish any thread-confined assumptions
— including mutating UI state only on Dispatchers.Main.
The big picture#
Text description of this diagram
A code walkthrough of one coroutine, shown twice. Two thread lanes — main and Dispatchers.IO — show where the coroutine is running at each step. The source pane highlights the active lines in a ViewModel and a repository. A click calls load, which launches into viewModelScope on the main dispatcher. The first suspension is the call to the repository's suspend function. Two async children start on the IO dispatcher's thread pool, and the parent suspends at await without blocking a thread. When both children complete, the continuation is dispatched back to main, and the StateFlow is updated, which is what triggers recomposition.
Watch the lanes as you step. The main lane is only highlighted at the start and the end. In the middle, the IO lane runs while the main thread is free — that is the whole performance argument for suspending functions over blocking calls.
Deep dive#
What suspend actually compiles to#
suspend fun fetchUser(id: String): User {
val response = api.get("/users/$id") // suspends here
return response.toUser() // resumes here
}The compiler rewrites that into a method taking a Continuation<User>. Inside, a when on a state
label decides where to resume:
// A schematic of the generated shape, not literal output.
fun fetchUser(id: String, cont: Continuation<User>): Any? {
val sm = cont as? FetchUserSM ?: FetchUserSM(cont)
return when (sm.label) {
0 -> {
sm.label = 1
val result = api.get("/users/$id", sm) // returns COROUTINE_SUSPENDED
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
sm.result = result
}
else -> { /* resumed here */ }
}
// …
}Two consequences follow directly:
- A suspension is cheap. It allocates a continuation object, not a thread, and it does not block the calling thread.
- The compiler must know the suspension point. That is why you cannot call a
suspendfunction from a non-suspend function withoutlaunchorasync— the compiler needs a state machine to suspend into.
Dispatching#
Suspension says what can pause; a dispatcher says where it resumes. The three you need:
| Dispatcher | Backed by | Use for |
|---|---|---|
Dispatchers.Main | the main Looper's Handler | UI state, Compose state, anything touching views |
Dispatchers.Default | a CPU-sized thread pool | parsing, sorting, computation |
Dispatchers.IO | a pool sized for blocking | disk, network, database |
viewModelScope.launch { // Main
val parsed = withContext(Dispatchers.Default) { // Default
parser.parse(bytes)
}
val stored = withContext(Dispatchers.IO) { // IO
dao.insert(parsed)
}
_state.value = UiState.Ready(stored) // Main again
}withContext is a round trip: it suspends the coroutine, dispatches the block elsewhere, waits for it
to finish, and resumes on the original dispatcher. That last part is the useful bit — you do not have
to remember to come back.
Structured concurrency#
Every coroutine runs inside a scope. The scope tracks its children and cancels them together:
suspend fun loadDashboard(id: String): Dashboard = coroutineScope {
val profile = async { profileApi.fetch(id) }
val feed = async { feedApi.fetch(id) }
val stats = async { statsApi.fetch(id) }
Dashboard(
profile = profile.await(),
feed = feed.await(),
stats = stats.await(),
)
}Three properties, all from coroutineScope:
- Parallel execution. The three
asynccalls start immediately and run concurrently. - Failure cancels siblings. If
profileApi.fetchthrows, the other two are cancelled and the exception propagates. - The parent waits.
loadDashboardcannot return until every child has completed or been cancelled.
That is the difference from launching three threads: with threads, a failure in one leaves the other two running and nobody notices.
// The anti-pattern: fire and forget.
fun load() {
viewModelScope.launch { profileApi.fetch(id) }
viewModelScope.launch { feedApi.fetch(id) }
}Two launches in the same scope are siblings, but the function returns immediately and nothing waits for
them. It is not wrong — the scope will cancel them — but it is not structured work either. Prefer a
coroutineScope with async when the results belong together.
Cancellation is cooperative#
Cancelling a coroutine does not kill a thread. It sets a flag, and the coroutine stops at the next suspension point that checks it.
val job = viewModelScope.launch {
repeat(1000) { i ->
ensureActive() // throws CancellationException if cancelled
process(i)
}
}
job.cancel()A tight loop that never suspends is not cancellable. ensureActive() (or yield()) inserts a
cooperative check. Library code that reads files and calls the network is cancellable because those
calls suspend.
Scopes that match Android lifetimes#
class NotesViewModel(
private val repository: NotesRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.notes.collect { notes ->
_state.value = UiState.Ready(notes)
}
}
}
}| Scope | Cancelled when | Use for |
|---|---|---|
viewModelScope | the ViewModel is cleared | state that outlives the screen |
lifecycleScope | the lifecycle is destroyed | Activity/Fragment-scoped work |
repeatOnLifecycle(STARTED) | the lifecycle drops below STARTED | collecting a Flow in a UI |
rememberCoroutineScope() | the composable leaves composition | UI-local work |
repeatOnLifecycle is the one that catches people. A lifecycleScope lasts until destroy, which is
longer than "visible". Collecting a directly in lifecycleScope keeps the collection running
while the user is on another screen.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { state -> render(state) }
}
}In Compose, collectAsStateWithLifecycle() packages that pattern into one call.3
Flow, StateFlow, and the UI boundary#
A Flow is cold: nothing runs until it is collected. StateFlow is hot and conflated: it always has a
current value and drops intermediate values a slow collector missed.
class SearchViewModel(
private val repository: SearchRepository,
) : ViewModel() {
private val query = MutableStateFlow("")
val results: StateFlow<List<Result>> = query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repository.search(q) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
fun onQueryChange(text: String) { query.value = text }
}Three decisions in that snippet are load-bearing:
flatMapLatestcancels the previous search when a new query arrives.WhileSubscribed(5_000)stops upstream work when the UI has been gone for five seconds, but keeps it alive across a quick rotation.stateInmakes the cold stream hot, with a value the UI can render immediately.
Coroutines and Compose#
Compose reads state during composition, so a coroutine that writes Compose state must write on the main thread:
@Composable
fun NotesScreen(viewModel: NotesViewModel) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.refresh() // runs in the composition's scope, on Main
}
when (val s = state) {
is UiState.Loading -> Spinner()
is UiState.Ready -> NotesList(s.notes)
is UiState.Error -> ErrorPane(s.error)
}
}LaunchedEffect(key) starts a coroutine when the key changes and cancels it when the composable
leaves. It is the Compose equivalent of a lifecycle-scoped launch, and it is cancelled automatically —
no manual job management.
Where coroutines surprise people#
Three behaviours that are correct and counter-intuitive:
A suspend function that never suspends is a normal call. If nothing in the body actually suspends,
the state machine runs to completion synchronously. That is why a suspend function can be called from
a test without a dispatcher and still work.
async starts eagerly by default. async { } on a CoroutineStart.DEFAULT begins executing
immediately, not when you await(). Use CoroutineStart.LAZY when you want to defer it.
Cancellation is not immediate. cancel() sets a flag; the coroutine stops at its next suspension
point. A finally block still runs, which is how resource cleanup works, and why a finally that
suspends must use withContext(NonCancellable).
Measuring the cost#
Suspension is cheap but not free. A continuation allocation per suspension point is negligible in a network call; it is not negligible in a loop of a million iterations. Two rules:
- Do not make a hot inner loop suspend for no reason; batch the work.
- Do not use
flow { emit(x) }per item when a sequence would do; the flow machinery costs more than the items.
Android 17 tightened memory limits per app and added generational GC to ART, both of which change the cost calculus slightly: allocations are cheaper to collect, but total memory is more strictly enforced.4
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
Coroutines are not a platform feature — they are a Kotlin library — which is why they work on every
supported API level without a compatibility layer. The platform's contribution is the lifecycle-aware
scopes (viewModelScope, lifecycleScope) that AndroidX provides.
Under the hood#
- Handler.javawhat Dispatchers.Main actually dispatches to
- ActivityThread.javathe Looper that every Main-dispatched continuation resumes on
- the coroutine library, vendored for platform components
- Looper.javathe thread-local loop that makes Main.immediate possible
Curiosity corner#
Dispatchers.Main.immediateruns the block inline if you are already on main, avoiding a queue round trip.viewModelScopeuses it by default, which is why setting state inlaunchdoes not cause an extra frame.- A single
asyncwith noawaitstill runs, and its exception is stored until you await it. UsecoroutineScopeand await everything, or you will miss failures. runBlockingon the main thread is an ANR waiting to happen. It exists formain()functions and tests, not for app code.
Try it#
adb shell ps -T -p $(adb shell pidof com.example.notes) -o PID,TID,NAME
adb logcat -s StrictMode
Check yourself#
Check yourself
A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
- docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
- docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩
- docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩