Android Engineer Hub

Kotlin and Coroutines: The Senior Android Engineer's Working Vocabulary

In short

Kotlin idioms and kotlinx.coroutines are the spine of every modern Android app. This deep-skill covers the language features senior engineers use weekly (sealed classes, data classes, scope functions, extension functions), the structured concurrency model that prevents leaks, the Flow / StateFlow / SharedFlow trichotomy that drives reactive UI, exception handling with supervisorScope, Dispatcher selection, and the kotlinx-coroutines-test machinery that makes asynchronous code deterministic. Read this and you will recognize the patterns in any well-written Android codebase from 2023 onward.

Key takeaways

  • Sealed classes + exhaustive `when` are how senior Android engineers model state honestly.
  • Structured concurrency means every coroutine runs inside a scope; cancel the scope and the children die with it.
  • Use `coroutineScope` for atomic work, `supervisorScope` when child failures must be isolated.
  • Never catch `CancellationException` without rethrowing — you are breaking the cancellation contract.
  • StateFlow holds the current UI state; SharedFlow delivers one-shot events; cold Flow is for caller-owned streams.
  • Collect flows with `repeatOnLifecycle(STARTED)` to avoid background work and event-replay bugs.
  • Inject dispatchers, test with `runTest` + `TestScope`, and step time with `advanceUntilIdle` and `advanceTimeBy`.
  • Turbine makes Flow assertions readable; let `runTest` fail your test on coroutine leaks.

Kotlin idioms every senior Android writes weekly

The Kotlin idioms that separate a junior implementation from a senior one are rarely about clever syntax — they are about modeling state honestly. **Sealed classes** are the single most important tool in this category. They let you enumerate every legal state of a screen, network call, or domain event so the compiler refuses to let you forget a case. Pair a sealed class with a `when` expression (not a `when` statement) and the compiler enforces exhaustiveness — adding a new state forces every consumer to handle it. ```kotlin sealed interface UiState { data object Loading : UiState data class Success(val items: List) : UiState data class Error(val message: String) : UiState } fun render(state: UiState) = when (state) { UiState.Loading -> showSpinner() is UiState.Success -> showList(state.items) is UiState.Error -> showError(state.message) } ``` Notice the `when` is used as an expression, which makes the exhaustiveness check non-optional. **Data classes** complement this by giving you free `equals`, `hashCode`, `toString`, `copy`, and component destructuring — indispensable for state objects flowing through Flow, Compose snapshot comparisons, and DiffUtil. **Scope functions** (`let`, `apply`, `also`, `run`, `with`) are the second idiom set worth mastering. The cheap heuristic: `apply` to configure a newly-built object and return it, `also` for side effects in a chain, `let` for null-safe transforms (`user?.let { send(it) }`), `run` to scope a block with a receiver and return a result. Picking the wrong one is rarely a bug, but picking the right one is what makes the call site read like English. **Extension functions** are how senior engineers add behavior without subclassing. Domain-specific extensions like `fun View.gone() { visibility = View.GONE }` or `fun Bundle.requireString(key: String) = getString(key) ?: error("Missing $key")` live in small `Extensions.kt` files and remove dozens of boilerplate lines across the codebase. Jake Wharton's libraries (e.g. RxBinding, Picnic) are a master class in restrained extension design — extensions stay near where they are used and read like part of the original API. Finally, prefer **immutable `val` + `data class.copy()`** over mutable state, and lean on **inline classes / value classes** (`@JvmInline value class UserId(val raw: String)`) to give primitive identifiers type safety with zero runtime overhead. These five idioms — sealed hierarchies, data classes, scope functions, extension functions, and value classes — are what makes Kotlin feel like Kotlin. Two more habits show up consistently in the codebases of senior Android engineers. **Top-level functions over utility classes**: a file called `StringExt.kt` containing `fun String.titleCase(): String = ...` is idiomatic; a `StringUtils` class with static methods is a Java refugee. **Named arguments at the call site** for any function that takes more than two parameters of the same type (`copy(isLoading = false, error = null)`) makes diffs readable and prevents the swapped-boolean bugs that plague long-lived APIs. Run `ktlint` or `detekt` to enforce these conventions automatically — Kotlin culture rewards consistency, and the cost of a linter is paid back the first time you onboard a new teammate.

Structured concurrency: CoroutineScope, Job, supervisorScope

Structured concurrency is the rule that every coroutine must run inside a scope, every scope owns a `Job`, and a scope cannot finish until all of its children finish. Cancel the scope and every child is cancelled; let a child throw an unhandled exception and the scope (by default) cancels its siblings. This eliminates the leak class that plagued Android for a decade — orphan background work outliving the screen that started it. On Android, you almost never construct a `CoroutineScope` by hand. You use `viewModelScope` (cancelled when the ViewModel clears), `lifecycleScope` (cancelled with the LifecycleOwner), or `repeatOnLifecycle(STARTED) { ... }` for collecting flows only while the UI is visible. These scopes already carry sensible defaults: `Dispatchers.Main.immediate` plus a `SupervisorJob`, so a child failure does not nuke the ViewModel. ```kotlin class JobsViewModel(private val repo: JobsRepository) : ViewModel() { fun load() = viewModelScope.launch { // supervisorScope so a profile failure doesn't cancel jobs supervisorScope { val jobs = async { repo.fetchJobs() } val profile = async { runCatching { repo.fetchProfile() }.getOrNull() } _state.value = UiState.Success(jobs.await(), profile.await()) } } } ``` The distinction between `coroutineScope` and `supervisorScope` is the single most-tested concept in coroutines interviews. `coroutineScope` propagates child failures upward and cancels siblings. `supervisorScope` isolates each child — a failure in one `async` does not cancel its siblings, so partial results are recoverable. Use `supervisorScope` (or a `SupervisorJob`) when concurrent work items are independent (parallel API calls feeding one screen). Use `coroutineScope` when the work items form a single logical unit (a transaction that must succeed or fail atomically). Two more rules separate juniors from seniors. First, **never swallow `CancellationException`**. A blanket `try { ... } catch (e: Exception)` will trap the very signal that structured concurrency relies on. Always rethrow it: `catch (e: Exception) { if (e is CancellationException) throw e; ... }` or use `runCatching` (which preserves cancellation). Second, **install a `CoroutineExceptionHandler`** on top-level scopes only. It only catches uncaught exceptions in `launch` (not `async`, where the exception is deferred to `await()`), and it only applies to the root coroutine. Internalizing these two rules will save you from the majority of production-only crashes that escape unit tests. **Dispatcher selection** is the third axis of structured concurrency. `Dispatchers.Main` runs on Android's main thread and must own all UI work; `Dispatchers.Main.immediate` short-circuits when you are already on Main (avoids an extra post). `Dispatchers.IO` is a 64-thread pool tuned for blocking I/O (disk, network, Room), and it shares a backing pool with `Dispatchers.Default`, which is sized to CPU cores for compute-bound work (JSON parsing, image processing). The right pattern for a repository is to switch dispatchers at the boundary: `withContext(Dispatchers.IO) { ... }` around the actual blocking call, and let the caller stay on Main. Avoid creating bespoke dispatchers (`newSingleThreadContext`) without a clear reason — they leak threads if you forget to close them, and the built-in pools are already optimized for the cases that matter.

Flow vs StateFlow vs SharedFlow — when each wins

`Flow` is a cold asynchronous stream — nothing happens until somebody calls `collect`. Each collector triggers a fresh execution of the upstream `flow { }` builder. Use cold `Flow` for one-shot work the caller owns: a database query (Room returns `Flow`), a network call mapped to a stream, a paginated list. Operators (`map`, `filter`, `flatMapLatest`, `combine`, `debounce`, `retry`) are pure and execute on the collector's coroutine context unless you interpose `flowOn(Dispatchers.IO)`. Backpressure is handled by suspension (the producer literally can't outrun the collector) plus the explicit `buffer`, `conflate`, and `collectLatest` operators. `StateFlow` is hot and conflated — it always has a current value, and new values overwrite stale ones. It is the canonical UI-state holder for a ViewModel. Late subscribers immediately receive the current state. `SharedFlow` is hot and configurable — it is the canonical event stream for fire-once side effects like 'navigate to detail' or 'show snackbar.' ```kotlin class JobsViewModel : ViewModel() { // StateFlow: always has a value, perfect for UI rendering private val _ui = MutableStateFlow(UiState.Loading) val ui: StateFlow = _ui.asStateFlow() // SharedFlow: events that must not be replayed on rotation private val _events = MutableSharedFlow(extraBufferCapacity = 1) val events: SharedFlow = _events.asSharedFlow() fun onJobClick(id: String) { viewModelScope.launch { _events.emit(NavEvent.OpenJob(id)) } } } ``` Three rules of thumb. First, **state is StateFlow, events are SharedFlow.** Replaying a snackbar on rotation is a bug. Replaying the current screen state on rotation is the whole point. Second, **collect with `repeatOnLifecycle(STARTED)`**, not `lifecycleScope.launch` directly. `repeatOnLifecycle` cancels collection when the UI goes to STOPPED and restarts it when it returns to STARTED, which prevents wasted work and the infamous 'flow producer keeps running while the app is in the background' leak. Third, **use `stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial)`** to convert a cold `Flow` to a hot `StateFlow` that survives configuration changes (the 5-second grace period covers rotation) but releases when the UI is gone for good. This single operator removes a category of manual-caching bugs. When in doubt: did the consumer just connect, and do they need a value right now? StateFlow. Is the value a one-time event? SharedFlow. Is the producer owned by the caller and tied to one read? Plain Flow. A few operator habits separate practitioners from beginners. Use `flatMapLatest` (not `flatMap`) for search-as-you-type — it cancels the previous query when a new one arrives. Use `combine` to fuse two state sources into one renderable state (`combine(jobsFlow, filtersFlow) { jobs, f -> jobs.filter(f) }`). Use `debounce` before network calls bound to user input. Reach for `retry` / `retryWhen` for transient I/O failures, and bound the retries with a predicate (`retry(3) { it is IOException }`) to avoid retry storms on permanent errors like `HTTP 401`. The Android docs on Flow contain the canonical reference for these operators and their backpressure semantics — read it once a year; the API surface evolves.

Testing coroutines: TestScope, runTest, advanceUntilIdle

Testing coroutines without virtual time is misery. The `kotlinx-coroutines-test` library replaces the dispatcher with a virtual scheduler so that `delay(10_000)` completes in microseconds, work is deterministic, and you can step time forward by hand. The entry point is `runTest { }`, which constructs a `TestScope` backed by a `StandardTestDispatcher` (or `UnconfinedTestDispatcher` for eager execution). ```kotlin @Test fun `loads jobs and emits Success`() = runTest { val repo = FakeJobsRepository(jobs = listOf(Job("1", "Android"))) val vm = JobsViewModel(repo) val states = mutableListOf() val job = launch(UnconfinedTestDispatcher(testScheduler)) { vm.ui.toList(states) } vm.load() advanceUntilIdle() // run all pending work in virtual time assertEquals(UiState.Loading, states.first()) assertTrue(states.last() is UiState.Success) job.cancel() } ``` The non-obvious patterns. **Inject the dispatcher.** Hardcoding `Dispatchers.IO` inside a ViewModel makes it untestable; instead accept a `CoroutineDispatcher` (or a `DispatcherProvider`) and pass `StandardTestDispatcher(testScheduler)` from the test. **Replace `Main` for tests** with `Dispatchers.setMain(testDispatcher)` in `@Before` and `Dispatchers.resetMain()` in `@After`, otherwise `viewModelScope` (which uses `Dispatchers.Main.immediate`) will throw on the JVM where there is no Android main looper. Master three time-control primitives. `advanceUntilIdle()` runs every scheduled task to completion and is the right default for 'just finish.' `advanceTimeBy(ms)` moves the virtual clock forward exactly that much, useful for asserting `debounce`, `delay`, and `timeout` behavior. `runCurrent()` runs only the tasks already due at the current virtual time, which is how you assert intermediate states (e.g., that `Loading` was emitted before `Success`). For Flow assertions, prefer Cash App's Turbine library — `flow.test { assertEquals(Loading, awaitItem()) }` is dramatically cleaner than rolling your own collector with mutable lists. Last principle: tests should fail loudly when coroutines leak. `runTest` throws `UncompletedCoroutinesError` if a child is still alive when the test finishes — do not catch it, fix the leak. That single behavior pays for everything else the test library does. Two debugging tools deserve a permanent home in your toolbox. **kotlinx-coroutines-debug** (or the `-Dkotlinx.coroutines.debug` JVM flag) tags every coroutine with a stable name and prints the full coroutine tree on crash, which turns 'something somewhere is hung' into a one-line diagnostic. **Naming coroutines** with `CoroutineName("jobs-load")` in the launch context makes log output traceable across dispatcher hops. Combine these with the Android Studio coroutines debugger and you can watch the live job hierarchy for a screen — invaluable when investigating jank or leaked work.

Frequently asked questions

Common pitfall
Using `GlobalScope.launch` in production code — it has no parent and leaks indefinitely.
Common pitfall
Catching `Exception` (or worse, `Throwable`) without rethrowing `CancellationException`.
Common pitfall
Emitting one-shot events from a `StateFlow` and watching them replay on rotation.
Common pitfall
Forgetting `flowOn(Dispatchers.IO)` and blocking the main thread inside a `flow { }` builder.
Common pitfall
Hardcoding `Dispatchers.IO` / `Dispatchers.Main` in classes you later need to unit test.
Common pitfall
Calling `.collect` directly in `lifecycleScope.launch` instead of inside `repeatOnLifecycle(STARTED)`.
Common pitfall
Treating `async` exceptions as if they propagate immediately — they surface only at `.await()`.

Sources

  1. Coroutines guide
  2. Kotlin coroutines on Android
  3. Kotlin Flow on Android
  4. kotlinx-coroutines-test API reference
  5. Jake Wharton — Kotlin idioms and library design
  6. Chris Banes — Android engineering and Compose / coroutines patterns

About the author. Blake Crosley founded ResumeGeni and writes about Android engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.