Zygote
Threads & concurrencyintermediate

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.

11 minAPI 21–37 · 17interactivequizverified 2026-10-27

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, Default and IO are 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.
  • viewModelScope is cancelled when the clears; lifecycleScope when the lifecycle is destroyed. Use them instead of hand-managed jobs.
  • Exceptions propagate to the caller; CancellationException is 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#

Loading diagram…
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:

  1. A suspension is cheap. It allocates a continuation object, not a thread, and it does not block the calling thread.
  2. The compiler must know the suspension point. That is why you cannot call a suspend function from a non-suspend function without launch or async — 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:

DispatcherBacked byUse for
Dispatchers.Mainthe main Looper's HandlerUI state, Compose state, anything touching views
Dispatchers.Defaulta CPU-sized thread poolparsing, sorting, computation
Dispatchers.IOa pool sized for blockingdisk, 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:

  1. Parallel execution. The three async calls start immediately and run concurrently.
  2. Failure cancels siblings. If profileApi.fetch throws, the other two are cancelled and the exception propagates.
  3. The parent waits. loadDashboard cannot 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)
            }
        }
    }
}
ScopeCancelled whenUse for
viewModelScopethe ViewModel is clearedstate that outlives the screen
lifecycleScopethe lifecycle is destroyedActivity/Fragment-scoped work
repeatOnLifecycle(STARTED)the lifecycle drops below STARTEDcollecting a Flow in a UI
rememberCoroutineScope()the composable leaves compositionUI-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:

  • flatMapLatest cancels 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.
  • stateIn makes 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#

Runtime changes9 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
247.0 Nougat
  • Hybrid JIT + AOT compilation in ART
3313 Tiramisu
  • The notification permission becomes a runtime permission
3717 Cinnamon Bun
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector

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#

Curiosity corner#

  • Dispatchers.Main.immediate runs the block inline if you are already on main, avoiding a queue round trip. viewModelScope uses it by default, which is why setting state in launch does not cause an extra frame.
  • A single async with no await still runs, and its exception is stored until you await it. Use coroutineScope and await everything, or you will miss failures.
  • runBlocking on the main thread is an ANR waiting to happen. It exists for main() functions and tests, not for app code.

Try it#

Count the threads a coroutine app creates

adb shell ps -T -p $(adb shell pidof com.example.notes) -o PID,TID,NAME

Check for coroutine-related StrictMode violations

adb logcat -s StrictMode

Check yourself#

Check yourself

1 / 12

A function does 300 ms of work on the main thread. What is the most accurate description of the consequence?

0 answered · 0 correct

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.

  1. docsKotlin documentationkotlinlang.orgaccessed 2026-10-27↩
  2. docsKotlin coroutines on Androiddeveloper.android.comaccessed 2026-10-27↩
  3. docsLifecycle of composablesdeveloper.android.comaccessed 2026-10-27↩
  4. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩
  5. docsAndroid for Kotlin developersdeveloper.android.comaccessed 2026-10-27↩
  6. docsProcesses and threads overview | App qualitydeveloper.android.comaccessed 2026-10-27↩