Introduction: The Uncollected Flow That Drained Our Users' Batteries
Early in our transition to Kotlin Flow, we built a "live location tracking" feature. The ViewModel exposed a StateFlow of location updates. The Compose UI collected it. It worked perfectly in development. But in production, users reported massive battery drain. The culprit? A developer had used LaunchedEffect(Unit) to collect the flow, but didn't account for the screen going into the background. The flow kept emitting and processing location data at 1-second intervals, even when the UI wasn't visible, because the coroutine wasn't tied to the lifecycle's STARTED state.
Kotlin Coroutines and Flow are incredibly powerful, but they introduce new classes of bugs: memory leaks, unhandled exceptions, and wasted CPU cycles. This guide moves beyond basic "Hello World" tutorials. We will cover the production-tested patterns, lifecycle-aware collection, and structured concurrency rules we use to build robust, scalable Android applications.

Structured Concurrency: The Foundation of Leak-Free Code
The most important concept in Kotlin Coroutines is structured concurrency. It means every coroutine is launched within a specific CoroutineScope, and when that scope is cancelled, all its child coroutines are automatically cancelled. This prevents memory leaks and orphaned background tasks.
| Scope | Lifetime | Best Use Case |
|---|---|---|
| `viewModelScope` | Until ViewModel is cleared | Fetching data, updating UI state in ViewModels |
| `lifecycleScope` | Until Lifecycle is destroyed | One-off UI actions tied to an Activity/Fragment |
| `rememberCoroutineScope` | Until Compose composition is removed | Handling click events or local Compose state updates |
| `CoroutineScope(SupervisorJob() + Dispatchers.Default)` | Manual cancellation | Long-running background workers not tied to UI |
💡 The Golden Rule
Never use GlobalScope. It detaches the coroutine from the application lifecycle, guaranteeing memory leaks if it holds references to Context or Views. Always use a lifecycle-aware scope.
Dispatchers: Injecting Them for Testability
Hardcoding Dispatchers.IO or Dispatchers.Main in your repositories or ViewModels makes them notoriously difficult to test. The production best practice is to inject the CoroutineDispatcher as a dependency, defaulting to the standard dispatchers.
class UserRepository(
private val api: ApiService,
// Inject dispatcher with a default value for production
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun fetchUsers(): List<User> = withContext(ioDispatcher) {
// This block runs on the injected dispatcher
api.getUsers()
}
}
In your tests, you can easily pass StandardTestDispatcher() to execute the code instantly and deterministically without dealing with real threads.
Exception Handling: The Hidden Traps
Coroutine exception handling is notoriously tricky. A common misconception is that a CoroutineExceptionHandler will catch all errors. In reality, it only catches uncaught exceptions in root coroutines (launched directly in the scope). Exceptions in async or child launch blocks will crash the app or be silently swallowed if not handled properly.
viewModelScope.launch {
// Use the 'catch' operator for Flow-specific error handling
userRepository.observeUsers()
.catch { exception ->
// Handle error, update UI state
_uiState.value = UiState.Error(exception.message ?: "Unknown error")
}
.onStart {
_uiState.value = UiState.Loading
}
.collect { users ->
_uiState.value = UiState.Success(users)
}
}
Pro Tip: For parallel operations where one failure shouldn't cancel the others, use supervisorScope instead of coroutineScope. This isolates failures to the specific child coroutine.
StateFlow vs SharedFlow: Choosing the Right Tool
Both are "hot" flows, but they serve fundamentally different purposes in Android architecture.
| Feature | StateFlow | SharedFlow |
|---|---|---|
| Initial Value | Required | Optional |
| Replay Value | Always 1 (the latest) | Configurable (0 to unlimited) |
| Equality Check | Conflates duplicate values | Emits every value |
| Primary Use Case | UI State (e.g., Loading, Success, Error) | One-off Events (e.g., Navigation, Toasts, Snackbar) |
class OrderViewModel : ViewModel() {
// Use StateFlow for persistent UI state
private val _uiState = MutableStateFlow<OrderUiState>(OrderUiState.Idle)
val uiState: StateFlow<OrderUiState> = _uiState
// Use SharedFlow for one-off events (replay = 0 ensures it's only consumed once)
private val _navigationEvent = MutableSharedFlow<String>(replay = 0)
val navigationEvent: SharedFlow<String> = _navigationEvent
fun onOrderPlaced() {
viewModelScope.launch {
_navigationEvent.emit("OrderSuccessScreen")
}
}
}
Collecting Flow in Jetpack Compose: The Lifecycle Trap
Using collectAsState() in Compose is convenient, but it collects the flow as long as the Composable is in the composition, even if the screen is in the background. This wastes CPU and battery.
The production standard is to use collectAsStateWithLifecycle(), which automatically pauses collection when the Lifecycle falls below a certain state (default is STARTED), and resumes it when the screen becomes visible again.
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
// CRITICAL: Only collects when the Lifecycle is at least STARTED
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UiState.Loading -> LoadingIndicator()
is UiState.Success -> UserList((uiState as UiState.Success).users)
is UiState.Error -> ErrorMessage((uiState as UiState.Error).message)
}
}
Advanced Flow Operators: Building a Robust Search
A common real-world scenario is a search bar. You want to debounce user input, ignore duplicate queries, and cancel previous network requests if the user types a new character. Flow makes this elegant.
class SearchViewModel(
private val repository: SearchRepository
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
init {
viewModelScope.launch {
searchQuery
.debounce(300) // Wait 300ms after the last keystroke
.distinctUntilChanged() // Ignore if the query hasn't actually changed
.flatMapLatest { query -> // Cancels previous search if a new query arrives
if (query.isBlank()) {
flowOf(emptyList())
} else {
repository.search(query).catch {
emit(emptyList()) // Fallback on error
}
}
}
.collect { results ->
_searchResults.value = results
}
}
}
fun onQueryChanged(query: String) {
searchQuery.value = query
}
}
Testing Coroutines: Controlling Virtual Time
Testing asynchronous code used to be a nightmare of Thread.sleep() and race conditions. Kotlin's kotlinx-coroutines-test library provides runTest, which uses a virtual clock to execute coroutines instantly and deterministically.
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule() // Sets Dispatchers.Main to TestDispatcher
private val repository = mockk<UserRepository>()
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
viewModel = UserViewModel(repository)
}
@Test
fun `loadUsers updates state to Success`() = runTest {
// Arrange
val fakeUsers = listOf(User(1, "John"))
coEvery { repository.fetchUsers() } returns fakeUsers
// Act
viewModel.loadUsers()
// Assert: advanceUntilIdle() ensures all suspended coroutines finish
advanceUntilIdle()
assertEquals(
UiState.Success(fakeUsers),
viewModel.uiState.value
)
}
}
Common Production Mistakes
- ●
❌ Blocking the thread: Calling blocking code (like
Thread.sleep()or synchronous Room/Network calls) inside a coroutine without switching toDispatchers.IOor usingwithContext(Dispatchers.IO). This freezes the app. - ●
❌ Ignoring Cancellation: Not checking
isActiveor handlingCancellationExceptionin long-running loops. When the scope is cancelled, the coroutine should stop immediately to free resources. - ●
❌ Over-collecting Flows: Using
LaunchedEffectwithout proper keys, causing the flow to be collected multiple times simultaneously, leading to duplicated network requests or state updates. - ●
❌ Using
GlobalScope: As mentioned, this is the #1 cause of memory leaks in Android coroutine usage. - ●
❌ Misusing
SharedFlowfor State: UsingSharedFlowwithout replay for UI state means late collectors (e.g., after a configuration change) will miss the initial data.
"Coroutines make asynchronous code look synchronous, but they do not eliminate the need to think about threads, lifecycles, and concurrency."
Frequently Asked Questions
Why did my Flow stop emitting after a configuration change?
Should I use `callbackFlow` for legacy callback-based APIs?
How do I handle multiple exceptions in a `supervisorScope`?
Is `StateFlow` a complete replacement for `LiveData`?
Conclusion
Kotlin Coroutines and Flow are not just syntactic sugar; they are a fundamental shift in how we manage concurrency and state in Android. By strictly adhering to structured concurrency, leveraging lifecycle-aware collection in Compose, choosing the right Flow type for the job, and writing deterministic tests with runTest, you can build applications that are not only highly responsive but also resilient and leak-free.
Ready to deepen your Android architecture skills? Check out our guides on [Advanced Jetpack Compose Performance Tuning], [Testing Android Repositories with Testcontainers], and [Mastering Dependency Injection with Hilt] to complete your modern Android toolkit.
