DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Kotlin Flow Patterns Every Senior Android Dev Must Know

What We're Building

In this workshop, I'll walk you through four Kotlin Flow patterns that separate production-grade Android code from tutorial-level code. By the end, you'll have working implementations of:

  • Correct sharing strategies with shareIn and stateIn
  • Backpressure handling with conflate and buffer
  • Resilient retry logic with exponential backoff
  • Deterministic Flow tests with Turbine

Let me show you a pattern I use in every project — and the mistakes I see even experienced developers make.

Prerequisites

  • Kotlin coroutines basics (suspend functions, CoroutineScope)
  • Familiarity with Flow, StateFlow, and SharedFlow
  • An Android project using viewModelScope (Jetpack lifecycle 2.5+)
  • Turbine added to your test dependencies

Step 1: Pick the Right Sharing Strategy

Here's the gotcha that will save you hours: stateIn conflates by design. If your upstream emits faster than collectors consume, intermediate values are dropped. That's perfect for UI state. It's devastating for one-shot events like navigation commands.

// UI state — stateIn is correct
val uiState: StateFlow<HomeUiState> = repository.observeData()
    .map { data -> HomeUiState.Success(data) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeUiState.Loading)

// One-shot events — shareIn with replay 0
val navigationEvents: SharedFlow<NavEvent> = _navChannel
    .receiveAsFlow()
    .shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 0)
Enter fullscreen mode Exit fullscreen mode

That WhileSubscribed(5000) timeout keeps the upstream alive during configuration changes — screen rotations typically complete well under 5 seconds — while still cleaning up when the user navigates away.

Step 2: Handle Backpressure Without Sinking Your Heap

When a producer emits faster than a consumer processes, reach for conflate() for UI-bound streams and buffer() when every emission matters.

// Sensor data: only the latest reading matters
sensorManager.observeAccelerometer()
    .conflate()
    .collect { reading -> updateUI(reading) }

// Analytics: every event must be recorded
analyticsStream
    .buffer(capacity = 64, onBufferOverflow = BufferOverflow.SUSPEND)
    .collect { event -> analyticsService.track(event) }
Enter fullscreen mode Exit fullscreen mode

The docs don't mention this, but the memory difference is dramatic. Processing 1,000 rapid emissions, conflate() holds O(1) memory while buffer(UNLIMITED) grows linearly. For UI streams, conflation is nearly always the right call.

Step 3: Build Production-Grade Retry Logic

The naive .retry(3) hammers a struggling server with immediate retries. Here's the minimal setup to get exponential backoff working properly:

fun <T> Flow<T>.retryWithBackoff(
    maxRetries: Long = 3,
    initialDelayMs: Long = 1000,
    maxDelayMs: Long = 30000,
    factor: Double = 2.0,
    retryOn: (Throwable) -> Boolean = { it is IOException }
): Flow<T> = this.retryWhen { cause, attempt ->
    if (attempt >= maxRetries || !retryOn(cause)) return@retryWhen false
    val delayMs = (initialDelayMs * factor.pow(attempt.toDouble()))
        .toLong()
        .coerceAtMost(maxDelayMs)
    delay(delayMs)
    true
}
Enter fullscreen mode Exit fullscreen mode

retryWhen gives you both the exception and the attempt count, which plain retry does not. The retryOn predicate ensures you only retry transient failures — retrying a 401 is pointless and a waste of your user's time.

Step 4: Test Flows Deterministically with Turbine

Turbine replaces flaky delay-based Flow tests with explicit, sequential assertions on each emission.

@Test
fun `emits loading then success`() = runTest {
    val viewModel = HomeViewModel(FakeRepository())

    viewModel.uiState.test {
        assertEquals(HomeUiState.Loading, awaitItem())
        assertEquals(HomeUiState.Success(testData), awaitItem())
        cancelAndConsumeRemainingEvents()
    }
}
Enter fullscreen mode Exit fullscreen mode

awaitItem() suspends until the next emission arrives. No advanceTimeBy guesswork, no arbitrary timeouts. Fast and deterministic.

Gotchas

  • Using stateIn for events: This is the single most common source of lost navigation signals in production. Events need shareIn with replay = 0.
  • buffer(UNLIMITED) on high-frequency streams: This is a memory leak waiting to happen. Always set a capacity or use conflate() for UI-bound data.
  • Retrying non-transient errors: A 401 or 404 won't magically succeed on retry. Always filter with a predicate.
  • Forgetting cancelAndConsumeRemainingEvents(): Turbine will fail your test if unconsumed emissions remain. Always clean up.
  • WhileSubscribed() without a timeout: Using WhileSubscribed(0) cancels upstream on every configuration change, triggering unnecessary reloads. Give it a 5-second window.

Wrapping Up

These four patterns cover the vast majority of Flow challenges you'll face in production Android work. Start by auditing your existing stateIn calls — if any of them carry events, switch to shareIn. Then add Turbine to your test suite. The improvement in test reliability alone is worth the migration effort.

For the official deep dive, the Kotlin Flow documentation and the Turbine GitHub repo are your best references.

Now go ship something solid.

Top comments (0)