Junior developers know how to launch a coroutine and collect a Flow. Senior developers understand structured concurrency, know when coroutines fail silently, and can design observable data pipelines using Flow operators.
Structured Concurrency
Every coroutine belongs to a scope. A structured parent job provides three guarantees:
- It tracks all coroutines launched within it.
- When cancelled, it cancels all children.
- It does not complete until all children complete.
viewModelScope.launch {
val result = async { repository.loadUser() } // child coroutine
val prefs = async { repository.loadPreferences() } // child coroutine
updateUi(result.await(), prefs.await())
} // scope waits for both async blocks before completing
If loadUser() throws a non-cancellation exception, the enclosing launch fails and cancels its children. The supervisor job in viewModelScope keeps unrelated sibling operations from being cancelled by this failure.
Coroutine Exception Handling
Both launch and async children propagate non-cancellation failures to a regular parent job. An async failure can cancel its parent and siblings before await() is called; await() also exposes the failure to its caller. Catching only around await() inside an already-cancelled parent does not restore that parent.
Catch recoverable failures around the structured operation, and preserve cancellation:
viewModelScope.launch {
try {
val user = coroutineScope {
async { repository.loadUser() }.await()
}
showUser(user)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: IOException) {
showNetworkError(failure)
}
}
The UI callbacks and repository above belong to your application; import the coroutine APIs and java.io.IOException. For a single request, call the suspending repository directly; the nested scope demonstrates a boundary that also works when combining several children.
A CoroutineExceptionHandler reports otherwise uncaught exceptions in appropriate root or supervised coroutines. It does not recover or restart a failed coroutine. Prefer local recovery when the application can act on a failure.
supervisorScope
Supervision lets sibling operations fail independently. Each child still needs an explicit failure policy; an unhandled launch exception can still reach the uncaught-exception mechanism.
viewModelScope.launch {
supervisorScope {
launch {
try {
loadRecentPosts()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: IOException) {
showPostsError(failure)
}
}
launch {
try {
loadUserProfile()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: IOException) {
showProfileError(failure)
}
}
}
}
Use this model when partial success is useful. Cancellation of the enclosing operation still cancels its children.
SharedFlow vs StateFlow
Both are hot flows whose existence is independent of a particular collector. Their producer lifetime still depends on how they are created and which scope owns the work.
StateFlow holds the latest value. New collectors receive it immediately. Used for UI state.
val uiState: StateFlow<HomeUiState> = MutableStateFlow(HomeUiState())
SharedFlow broadcasts to active collectors. A default MutableSharedFlow() has no replay or extra buffer: an emission with no subscribers is lost. It does not guarantee exactly-once delivery. Choose state with explicit acknowledgement when an action must survive a stopped UI or configuration change.
private val _events = MutableSharedFlow<HomeEvent>()
val events: SharedFlow<HomeEvent> = _events.asSharedFlow()
fun onSaveClicked() {
viewModelScope.launch {
_events.emit(HomeEvent.ShowSaveConfirmation)
}
}
In an Activity, collect while started using repeatOnLifecycle. For a Fragment view, use viewLifecycleOwner.lifecycleScope and its lifecycle instead:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.events.collect { event ->
when (event) {
is HomeEvent.ShowSaveConfirmation -> showSnackbar("Saved")
}
}
}
}
A raw action stored in StateFlow may be handled again after collection restarts. If an action is modeled as durable UI state, include acknowledgement or an idempotent handling policy. With a non-replaying SharedFlow, account for actions emitted while the UI is not collecting.
Key Flow Operators
// Transform values
flow.map { it.toUiModel() }
// Filter values
flow.filter { it.isActive }
// Combine two flows, emit when either emits
combine(flowA, flowB) { a, b -> Pair(a, b) }
// Pair corresponding emissions from each flow
flowA.zip(flowB) { a, b -> a + b } // waits for pairs
// Cancel the previous inner flow when a new debounced query arrives
searchQuery
.debounce(300)
.flatMapLatest { query -> repository.search(query) }
.collect { results -> updateUi(results) }
flatMapLatest is one of the most useful operators for search, because a new debounced query cancels the previous inner flow. The underlying network adapter must support cooperative cancellation to cancel its actual request.
Converting Flow to StateFlow in ViewModel
val posts: StateFlow<List<Post>> = repository.observePosts()
.map { entities -> entities.map { it.toPost() } }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
WhileSubscribed(5000) keeps the upstream Flow active for 5 seconds after the last collector disappears. This avoids restarting upstream for collector gaps shorter than that timeout, such as many rotations. Longer gaps can stop and later restart upstream.
callbackFlow
When you need to wrap a callback-based API into a Flow:
fun locationUpdates(client: FusedLocationProviderClient): Flow<Location> =
callbackFlow {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { trySend(it) }
}
}
client.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
awaitClose {
client.removeLocationUpdates(callback)
}
}
awaitClose runs when the Flow is cancelled, ensuring the callback is always unregistered.
Testing Coroutines
Use kotlinx-coroutines-test and replace the main dispatcher:
@Test
fun `loading state is true while request is in flight`() = runTest {
val viewModel = MyViewModel(fakeRepository)
viewModel.load()
// Check state during suspension
assertTrue(viewModel.state.value.isLoading)
advanceUntilIdle()
assertFalse(viewModel.state.value.isLoading)
}
Use Turbine for Flow testing:
@Test
fun `emits updated items after sync`() = runTest {
viewModel.items.test {
assertEquals(emptyList(), awaitItem())
viewModel.sync()
assertEquals(listOf(item1, item2), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
Practice
Build a search screen: a text field that triggers an API call. Use debounce(300) and flatMapLatest so in-flight requests are cancelled when the user types. Wrap the result in a StateFlow. Write a test that verifies previous calls are cancelled when a new query arrives within the debounce window.
Summary
Structured concurrency guarantees child lifecycle. launch propagates failures immediately; async holds them until await. Use supervisorScope for independent children. Use StateFlow for UI state and SharedFlow for events. Master flatMapLatest, combine, debounce, and callbackFlow to build reliable data pipelines.
Exercise
Write coroutine tests for one failing child in a regular scope, independently handled failures under supervision, and cancellation of the parent. For event delivery, test emissions with no collector and after a collector restarts. Explain which outcomes your UI requires before choosing replay or acknowledgement.