Chapter 6
Concurrency
Coroutines · Structured Concurrency · Flow · Cancellation · Exception Handling
Ready to practise interactively?
Explore this chapter with quizzes, diagrams, and real-world examples in the full interactive experience.
Structured Concurrency
Structured concurrency means every coroutine belongs to a scope. When the scope is cancelled, all child coroutines are cancelled. No coroutine leaks.
| Scope | Lifetime | Use For |
|---|---|---|
| viewModelScope | Cancelled when ViewModel is cleared | All ViewModel coroutines — safe default |
| lifecycleScope | Cancelled when lifecycle owner is destroyed | UI-layer work tied to Activity/Fragment |
| rememberCoroutineScope() | Cancelled when composable leaves composition | Coroutines launched from Compose events |
| GlobalScope | App lifetime — never cancelled automatically | AVOID. Almost always wrong. Causes leaks. |
Recommended Libraries
- kotlinx-coroutines-core — Core coroutines library. Provides launch, async, Flow, channels. Essential for any Kotlin project.
- kotlinx-coroutines-android — Android extensions for coroutines. Provides Dispatchers.Main and viewModelScope/lifecycleScope.
- lifecycle-viewmodel-ktx — Provides viewModelScope for ViewModel. Auto-cancels when ViewModel is cleared.
- lifecycle-runtime-ktx — Provides lifecycleScope and repeatOnLifecycle for lifecycle-aware coroutine collection.
Exception Handling
Understanding exception propagation in coroutines is critical.
- CoroutineExceptionHandler — catches uncaught exceptions in launch { }; does not affect async { }
- SupervisorScope / SupervisorJob — child failure does not cancel siblings or parent. Use in ViewModel when parallel tasks are independent.
- try/catch in coroutine body — preferred for recoverable errors; handle at the call site
- async { }.await() — exceptions propagate to await(); wrap in try/catch
Cancellation
Cooperative cancellation is a core concept in Kotlin coroutines.
- Cancellation is cooperative — your code must check isActive or call suspending functions to honour it
- withContext(NonCancellable) — use only for cleanup in finally blocks that must complete
- Never catch CancellationException and swallow it — rethrow always
Flow — Hot vs Cold
Understanding the difference between hot and cold flows is essential.
| Type | Behaviour | Example |
|---|---|---|
| Cold Flow | Starts executing only when collected. Each collector gets its own execution. | flow { }, flowOf(), Room DAO Flow |
| SharedFlow (hot) | Broadcasts to multiple collectors. Keeps running without collectors. | Events, one-time UI actions |
| StateFlow (hot) | Hot, always has a value. New collectors get current value immediately. | UI state in ViewModel |
Critical Flow Operators
Know these operators for interviews — they come up frequently.
| Operator | What It Does | Use Case |
|---|---|---|
| flatMapLatest | On each emission, cancels previous inner flow, starts new one | Search-as-you-type: cancel previous network call on new keystroke |
| debounce(ms) | Emits only after no new values for specified duration | Search input — wait for user to stop typing |
| conflate() | Drops intermediate values if collector is slow | Live location updates — only latest position matters |
| buffer() | Runs producer and collector concurrently with a buffer between them | Decouples fast producer from slow consumer |
| distinctUntilChanged() | Only emits when value actually changes | Avoid recomposition/re-render on identical state |
| combine() | Combines latest values from multiple flows | Form validation from multiple input flows |
Dispatchers
Choosing the right dispatcher for your work is important for performance.
| Dispatcher | Thread Pool | Use For |
|---|---|---|
| Dispatchers.Main | Main/UI thread | UI updates, StateFlow emissions, Compose state changes |
| Dispatchers.IO | Elastic thread pool (up to 64) | Network calls, file I/O, Room queries |
| Dispatchers.Default | CPU-bound pool (= CPU core count) | Sorting, JSON parsing, heavy computation |
| Dispatchers.Unconfined | Caller's thread until first suspension | Testing only — avoid in production |
stateIn & shareIn — Hot Flow Lifecycle Operators
stateIn and shareIn convert cold repository flows into hot flows that the ViewModel exposes to the UI. Getting the SharingStarted strategy wrong causes either battery drain or stale data.
- SharingStarted.WhileSubscribed(5_000) — upstream stays alive 5s after last collector stops; handles rotation without restarting DB/network
- SharingStarted.Eagerly — starts immediately, never stops; use for data needed at app start
- SharingStarted.Lazily — starts on first collector, never stops; use if restart cost is low
- replay in shareIn — new late subscribers get the last N emissions; replay=1 makes it behave like StateFlow
- Never use stateIn/shareIn directly in a lazy property — it starts a coroutine at first access, not at ViewModel creation
// stateIn — converts cold Flow to StateFlow in ViewModel
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
val feed: StateFlow<List<Post>> = repo.observePosts()
.stateIn(
scope = viewModelScope,
// WhileSubscribed(5_000) — keep upstream alive 5s after last collector
// disappears (e.g. screen rotation). Avoids restarting the DB query.
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
// shareIn — multicast a cold flow to multiple downstream collectors
val locationUpdates: SharedFlow<Location> = gpsRepository.locationFlow()
.shareIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
replay = 1 // new collectors get the last emitted location immediately
)Interview tip: When asked how you expose a repository flow to Compose, say: stateIn with SharingStarted.WhileSubscribed(5000). The 5-second window survives rotation without restarting the upstream database query.
Flow Testing with Turbine, runTest & advanceTimeBy
Testing coroutines and Flows requires controlling virtual time. runTest replaces runBlocking for coroutine tests and makes time controllable. Turbine provides a clean assertion API for Flows.
- runTest — replaces runBlocking for coroutines tests; uses TestCoroutineScheduler to control virtual time
- advanceTimeBy(ms) — advances the virtual clock without actually waiting; essential for testing debounce/delay
- Turbine test { } — awaitItem(), expectNoEvents(), awaitComplete(), awaitError() — clean Flow assertion DSL
- UnconfinedTestDispatcher — runs coroutines eagerly without delays; useful for simple state machine tests
- StandardTestDispatcher — manual time control with advanceUntilIdle(); default in runTest since coroutines 1.6
// Turbine — test Flow emissions declaratively
@Test
fun `search debounce emits after 300ms`() = runTest {
val viewModel = SearchViewModel(fakeRepo)
viewModel.query.test {
viewModel.onSearchInput("kotlin")
// Before debounce fires — no emission yet
expectNoEvents()
// Advance virtual clock past debounce window
advanceTimeBy(301)
// Now the debounced emission should arrive
assertThat(awaitItem()).isEqualTo("kotlin")
cancelAndIgnoreRemainingEvents()
}
}
// Test StateFlow state transitions
@Test
fun `loading then success state`() = runTest {
val viewModel = FeedViewModel(fakeFeedRepo)
viewModel.uiState.test {
assertThat(awaitItem()).isEqualTo(UiState.Loading)
viewModel.loadFeed()
assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
cancelAndIgnoreRemainingEvents()
}
}Recommended Libraries
- Turbine — Testing library for Kotlin Flows by Cash App. Provides awaitItem(), expectNoEvents(), test { } DSL for clean Flow assertions.
- kotlinx-coroutines-test — Official coroutines testing library. runTest, TestCoroutineScheduler, advanceTimeBy, UnconfinedTestDispatcher.
Interview tip: When asked how to test a debounced search Flow, say: runTest with advanceTimeBy to skip the debounce window, and Turbine's test { } block to assert the emitted items.