Android – Coroutines and Flow

September 10, 20268 min readUpdated 10/11/2026

Every interesting thing this app does waits for something: a network response, a disk read, a debounce, a payment to settle. Kotlin coroutines are how it waits without blocking the main thread, and Flow is how values that change over time get from where they are produced to the screen.

Coroutines are easy to start and easy to get subtly wrong. Almost every mistake is about one question — who owns this coroutine, and when does it stop? — so this lesson is organised around ownership.

Structured concurrency

A suspend function can pause without blocking a thread, and resume later. You can only call one from another suspend function or from a coroutine, and every coroutine is launched in a scope. The scope is the owner: cancel it, and every coroutine launched in it is cancelled; a coroutine that fails reports its failure to its scope. Nothing is left running that nobody can stop.

That rule is what makes coroutines manageable, and this app uses exactly three scopes.

viewModelScope

Work that belongs to one screen is launched in its ViewModel's scope, which is cancelled when the screen is gone for good:

fun load(showsLoadingState: Boolean = true) {
    loadJob?.cancel()
    loadJob = viewModelScope.launch {
        // A quiet refresh keeps showing the list it has; only a first load shows a spinner.
        if (showsLoadingState || _state.value.valueOrNull == null) _state.value = UiState.Loading
        try {
            _state.value = UiState.resolved(repository.myOrders(page = 0, size = PAGE_SIZE).content)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            _state.value = UiState.failure(e, "Could not load your orders.")
        }
    }
}

Leave the Orders tab and the request in flight is cancelled with it. Rotate the phone and it is not, because the ViewModel survives. No manual cancellation anywhere.

An application scope, for work that must outlive a screen

Some work must not be cancelled when a screen goes. A customer who adds a pizza and closes the sheet has not asked for the cart save to be abandoned. That work is launched in a scope that lives as long as the process, provided once by Hilt:

fun provideApplicationScope(): CoroutineScope =
    // SupervisorJob: one failed child (a cart save) must not cancel its siblings (a toast
    // timer) — with a plain Job, the first exception would take the whole scope down with it.
    // Main.immediate: the stores' state is driven from the UI, as a `@MainActor` type is in
    // Swift. Real I/O moves off the main thread inside OkHttp and DataStore, not here.
    CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

Two choices in one line. SupervisorJob: with a plain Job, the first child to fail cancels the whole scope — one failed cart save would silently kill every toast timer and every later save for the rest of the session. A supervisor lets children fail independently. And Dispatchers.Main.immediate: the stores drive UI state, so they run on the main thread, much as Swift's stores are @MainActor.

It is injected with a qualifier rather than reached for as GlobalScope, for one reason: a test can hand the store its own scope and control time. GlobalScope cannot be replaced, and a test that cannot control when a debounced save fires is a test that sleeps.

The caller's scope

The third "scope" is the caller's. A suspend function that launches nothing just runs inside whoever called it, and is cancelled with them. Most of the app's code is this kind, which is why most of it never mentions a scope at all.

Threads: main-safety

Coroutines launched on the main dispatcher run on the main thread — so a coroutine that does slow blocking work freezes the UI as surely as a plain function would. The Android convention that avoids it is main-safety: every suspend function must be safe to call from the main thread, moving its own blocking work elsewhere.

Retrofit, OkHttp, Room and DataStore are already main-safe; they switch threads internally. So most of this app never thinks about threads. The exception is code that calls a blocking API itself — the Keystore encryption:

override suspend fun string(key: StorageKey): String? {
    val stored = store.string(key) ?: return null
    return withContext(Dispatchers.IO) { decrypt(stored) }
}

withContext(Dispatchers.IO) runs the block on a thread pool sized for blocking I/O and returns to the caller's dispatcher with the result. The caller — a ViewModel on the main thread — never knows.

Parallel requests

The menu needs products, toppings and crusts: three independent requests. Awaiting them one after another would triple the wait. async starts each in parallel and await collects the results:

val catalogue = coroutineScope {
    val products = async { repository.products() }
    val toppings = async { repository.toppings() }
    val crusts = async { repository.crusts() }
    Catalogue(products.await(), toppings.await(), crusts.await())
}

The coroutineScope { } around them is what makes this safe. It is a scope that waits for all its children, and if any one fails, it cancels the others and rethrows. Without it, a failed products request would leave the toppings and crusts requests running into nowhere. The profile loads addresses and cards the same way.

Cancel and replace

Starting a new job and cancelling the previous one is a pattern you will write constantly. The menu uses it so a slow first response cannot land after a fast retry and overwrite fresher data:

fun load() {
    loadJob?.cancel()
    loadJob = scope.launch { performLoad() }
}

The cart uses the same three lines as a debounce. Three taps on "+" should be one network write, not three:

persistJob?.cancel()
persistJob = scope.launch {
    delay(persistDebounce)
    persist()
}

Each change cancels the pending write and schedules a new one 300ms out. delay is cancellable — it throws CancellationException when its job is cancelled — so a superseded write never reaches persist(). There is no timer handle to clear and no "is this still current?" flag. Cancellation is the debounce.

A loop that stops itself

After a payment, the receipt asks the server whether the order is paid, every two seconds, up to ten times:

private suspend fun poll() {
    _state.value = UiState.Loading

    repeat(maxAttempts) { attempt ->
        try {
            val order = repository.paymentStatus(orderId)
            _state.value = UiState.Loaded(order)
            if (!order.status.isSettling) return
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            // Keep showing the last good receipt if there is one; a blip mid-poll is not news.
            if (_state.value.valueOrNull == null) _state.value = UiState.failure(e, "Could not load the order.")
            return
        }
        // No sleep after the final attempt — it would delay the "still processing" message.
        if (attempt < maxAttempts - 1) delay(pollInterval)
    }

    _hasStoppedPolling.value = true
}

An ordinary loop with an ordinary delay. It is launched from init in viewModelScope, so leaving the screen cancels the scope, the delay throws, and the loop ends. Compare that with a Handler and postDelayed, or a Timer, each of which keeps firing until someone remembers to stop it. Notice also that the loop does not sleep after its last attempt: that would delay the "still processing" message by two seconds for nothing.

Shared state without locks

The auth token is read by the network interceptor, written by sign-in and cleared by sign-out — possibly at the same moment. Where Java would use synchronized, coroutine code uses a Mutex:

override suspend fun currentToken(): String? = mutex.withLock {
    if (!hasLoaded) {
        cachedToken = try {
            secureStore.string(StorageKey.AUTH_TOKEN)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            Log.w(TAG, "Stored token could not be read; treating it as signed out.", e)
            null
        }
        hasLoaded = true
    }
    cachedToken
}

The difference matters. synchronized blocks the waiting thread; if that thread is the main thread and the lock holder is reading from disk, the UI freezes. withLock suspends the waiting coroutine instead, and the thread is free to draw frames. (The iOS app uses an actor for the same job.)

Flow: values over time

A suspend function returns one value. A Flow emits many, over time. The kind this app uses everywhere is StateFlow: it always holds a current value, emits each new one to its collectors, and skips emitting a value equal to the last.

Updating state atomically

private fun dispatch(action: CartAction) {
    _state.update { CartReducer.reduce(it, action) }
    schedulePersist()
}

update { } reads the current value, applies the function and writes the result — retrying if another thread changed the value in between. Writing _state.value = reduce(_state.value, action) instead has a gap between the read and the write, and two quick taps from two coroutines can lose one.

Derived flows

The cart's totals are computed from the cart, so they are a flow derived from it:

val totals: StateFlow<CartTotals> = _state
    .map { CartPricing.totals(it.items, it.orderType) }
    .stateIn(scope, SharingStarted.Eagerly, CartTotals.EMPTY)

map transforms each emission; stateIn turns the result back into a StateFlow, started in a scope, with an initial value. The started policy is a real choice:

  • Eagerly — compute from the moment the store exists. Right for the cart's totals, which other code reads synchronously via .value.
  • WhileSubscribed(5_000) — compute only while someone collects, and keep going for five seconds after the last collector leaves. Right for a screen's derived value:
val cartCount: StateFlow<Int> = cartStore.totals
    .map { it.itemCount }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0)

The five seconds is not arbitrary: it is longer than a rotation, so turning the phone does not stop and restart the upstream work, and short enough that a backgrounded app stops computing promptly.

Reacting to changes

The Orders tab should reload when the signed-in account changes — and only then:

viewModelScope.launch {
    authStore.state.map { it.user?.id }.distinctUntilChanged().collect { userId ->
        if (userId == null) _state.value = UiState.Idle else load()
    }
}

map { it.user?.id } narrows the auth state to the one thing this screen cares about; distinctUntilChanged stops an unrelated change — a spinner flag, an error message — from triggering a reload; collect runs for every distinct account, including "nobody". The collection lives as long as the ViewModel.

Into Compose

The last step is the one from the state lesson: collectAsStateWithLifecycle() at the top of a screen, which collects while the screen is at least started and pauses in the background. A StateFlow is hot — it holds its value whether anyone is listening — so pausing collection loses nothing; the screen gets the current value the moment it resumes.

What about one-off events?

You will see SharedFlow and Channel used to send a ViewModel's one-off events — "navigate now", "show the payment sheet" — to the screen. This app deliberately does not. An event emitted while the screen is between Activities (mid-rotation) can be lost or delivered twice. Instead the ViewModel puts the event in its state, and the screen clears it once handled — the payments lesson shows the pattern end to end.

Next

Stores, ViewModels, repositories, reducers — the pieces are all on the table now. The next lesson puts them in order: the layers of this app, which way the dependencies point, and why.