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.

Open Interactive Chapter →

Structured Concurrency

Structured concurrency means every coroutine belongs to a scope. When the scope is cancelled, all child coroutines are cancelled. No coroutine leaks.

ScopeLifetimeUse For
viewModelScopeCancelled when ViewModel is clearedAll ViewModel coroutines — safe default
lifecycleScopeCancelled when lifecycle owner is destroyedUI-layer work tied to Activity/Fragment
rememberCoroutineScope()Cancelled when composable leaves compositionCoroutines launched from Compose events
GlobalScopeApp lifetime — never cancelled automaticallyAVOID. 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.

TypeBehaviourExample
Cold FlowStarts 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.

OperatorWhat It DoesUse Case
flatMapLatestOn each emission, cancels previous inner flow, starts new oneSearch-as-you-type: cancel previous network call on new keystroke
debounce(ms)Emits only after no new values for specified durationSearch input — wait for user to stop typing
conflate()Drops intermediate values if collector is slowLive location updates — only latest position matters
buffer()Runs producer and collector concurrently with a buffer between themDecouples fast producer from slow consumer
distinctUntilChanged()Only emits when value actually changesAvoid recomposition/re-render on identical state
combine()Combines latest values from multiple flowsForm validation from multiple input flows

Dispatchers

Choosing the right dispatcher for your work is important for performance.

DispatcherThread PoolUse For
Dispatchers.MainMain/UI threadUI updates, StateFlow emissions, Compose state changes
Dispatchers.IOElastic thread pool (up to 64)Network calls, file I/O, Room queries
Dispatchers.DefaultCPU-bound pool (= CPU core count)Sorting, JSON parsing, heavy computation
Dispatchers.UnconfinedCaller's thread until first suspensionTesting 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
    )
SeniorUses stateIn to expose StateFlow from ViewModel
StaffChooses the right SharingStarted strategy; explains the 5s rotation window and replay semantics
PrincipalDefines the team's ViewModel-to-UI data binding contract; identifies unnecessary upstream restarts at scale

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.
SeniorWrites unit tests for ViewModels with fake repositories
StaffTests Flow timing with runTest + advanceTimeBy + Turbine; injects TestDispatcher for determinism
PrincipalDefines the testing pyramid for async code; ensures coroutine tests run in CI with deterministic timing

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.

Test your knowledge

This chapter includes 7 quiz questions covering all core concepts. Open the interactive experience to test yourself.

Start Quiz →