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.