← Back to Articles
Android Development Kotlin Coroutines Kotlin Flow Jetpack Compose Mobile Architecture Asynchronous Programming

Kotlin Coroutines and Flow in Production: Advanced Patterns, Leak Prevention, and Testing

Moving beyond basic tutorials: Learn how to build robust, leak-free Android apps using Kotlin Coroutines and Flow. Master structured concurrency, lifecycle-aware Compose collection, StateFlow vs SharedFlow, and production-ready testing patterns.

July 2026
28 min read
Written by Engineering Team

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.

Modern Android Architecture with Coroutines and Flow
A robust architecture separates concerns: Repositories emit Flow, ViewModels transform state, and Compose collects lifecycle-aware updates.

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.

InjectableDispatcher.kt
kotlin
              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.

SafeFlowCollection.kt
kotlin
              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)
EventHandling.kt
kotlin
              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.

ComposeCollection.kt
kotlin
              @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.

SearchViewModel.kt
kotlin
              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.

ViewModelTest.kt
kotlin
              @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 to Dispatchers.IO or using withContext(Dispatchers.IO). This freezes the app.

  • Ignoring Cancellation: Not checking isActive or handling CancellationException in long-running loops. When the scope is cancelled, the coroutine should stop immediately to free resources.

  • Over-collecting Flows: Using LaunchedEffect without 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 SharedFlow for State: Using SharedFlow without 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?
If you are using a cold `Flow` (created with the `flow {}` builder) and collecting it in a `LaunchedEffect`, the collection restarts on recreation. If you need state to survive configuration changes, hold the `StateFlow` in the `ViewModel`, which survives the recreation.
Should I use `callbackFlow` for legacy callback-based APIs?
Yes. `callbackFlow` is the idiomatic way to bridge callback-based APIs (like Firebase listeners or Bluetooth callbacks) into Kotlin Flow. Remember to call `close()` in the `awaitClose` block to prevent memory leaks.
How do I handle multiple exceptions in a `supervisorScope`?
In a `supervisorScope`, a child coroutine's failure does not cancel its siblings. However, you still need to `try-catch` inside the child coroutine (or use the `catch` operator for Flows) to handle the error gracefully, otherwise the exception is simply logged and the child stops.
Is `StateFlow` a complete replacement for `LiveData`?
For new projects, yes. `StateFlow` is part of the Kotlin standard library, integrates seamlessly with Coroutines, and works perfectly with Jetpack Compose. `LiveData` is considered legacy for new architecture, though it remains useful for bridging to older View-based systems.

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.

We use cookies to improve your experience.