Coroutines and Flow have become the backbone of asynchronous data handling in Kotlin, yet many teams struggle to move beyond basic usage. The promise of structured concurrency quickly unravels when Flows leak, contexts get lost, or cancellation behaves unpredictably. This guide offers a practical look at curating a coroutine architecture that prioritizes Flow quality — not through abstract principles, but through concrete patterns, trade-offs, and debugging approaches we have seen work in real projects.
We assume you already know how to launch a coroutine and collect a Flow. The question is: how do you design your data streams so they remain predictable, testable, and maintainable as your system grows? That is what we aim to answer.
Why Flow Quality Matters and What Breaks Without It
When we talk about Flow quality, we mean how well your data streams handle lifecycle, cancellation, backpressure, and error recovery. A low-quality Flow might work fine in a simple screen but cause silent data loss or memory leaks in a production app. The symptoms are familiar: stale data appearing after configuration changes, missed emissions during rapid updates, or crashes from uncaught exceptions deep in a chain.
The root cause is often an architecture that treats Flow as a drop-in replacement for callbacks or RxJava without respecting its contract. For example, using flowOn incorrectly can shift collection context unexpectedly, leading to main-thread violations. Or using catch too early swallows errors that should propagate to the UI. These are not theoretical edge cases; we have seen them in code reviews across multiple projects.
Another common failure mode is ignoring the lifecycle of the collector. A Flow that emits indefinitely without respecting cancellation can keep running even after the UI is destroyed, wasting resources and potentially causing crashes when the collected data tries to update a detached view. This is especially problematic in Android, where lifecycle-aware collection is essential but often implemented incorrectly.
Finally, without deliberate Flow quality practices, testing becomes brittle. Flows that rely on global dispatchers or external state are hard to unit test, leading to integration tests that are slow and flaky. Teams end up avoiding tests for data streams altogether, which compounds the problem over time.
The Cost of Neglect
We have seen projects where a single misconfigured Flow caused a cascade of issues: a hot Flow that was not properly scoped to a ViewModel leaked into retained fragments, causing duplicate network requests and inconsistent state. The fix was not complex — using stateIn with the right sharing strategy — but the debugging effort cost days. More importantly, the team lost confidence in their data layer, leading to defensive code that further complicated the architecture.
What You Need to Settle Before Designing Flows
Before writing a single Flow, your team should agree on a few foundational decisions. These are not technical choices you can defer; they shape every data stream in your application. The first is the coroutine scope strategy. Will each ViewModel own its own scope? Will you use a global scope for some long-running operations? The answer affects how Flows are created and collected.
Second, choose a consistent approach for converting other async primitives to Flow. If you use callbacks, define a standard extension function pattern. If you have RxJava observables, agree on a conversion strategy that preserves cancellation and error handling. Without this, every developer reinvents the wheel, and the resulting Flows behave differently across the codebase.
Third, decide on the default dispatcher for your data layer. Many teams default to Dispatchers.IO for network and database calls, but that is not always optimal. CPU-intensive transformations might benefit from Dispatchers.Default, while UI updates need Dispatchers.Main. The key is to be explicit and document the reasoning, so future maintainers understand the trade-offs.
Understanding Flow Types
You also need to decide which Flow builders to use and when. Cold Flows (flow { }) are the default and work well for one-shot operations or streams where each collector triggers its own work. Hot Flows (SharedFlow and StateFlow) are better for events that should be shared across multiple collectors or for representing state that changes over time. The wrong choice leads to either unnecessary work (multiple collectors triggering the same work) or missed emissions (cold Flows that restart on each collection).
Finally, establish a testing strategy for Flows early. Will you use kotlinx-coroutines-test with TestDispatcher? How will you handle time-based operators like debounce or sample? These decisions affect how you write your production code, because testability should be built in, not retrofitted.
Core Workflow: Building a Quality Flow Step by Step
Let us walk through the process of designing a Flow for a typical use case: observing a user's profile from a remote API with local caching. The goal is a stream that emits cached data first, then fetches fresh data, and handles errors gracefully without crashing or leaking.
Start with the data source. Create a function that returns a Flow from the network, using flow { } and wrapping the API call in a try-catch. Emit the result or throw a custom exception. Then do the same for the local cache. Now you have two cold Flows.
Next, combine them. Use flowOf or concat to emit the cache first, then the network. But be careful: if the network Flow throws, you want to catch that error and still emit the cached data. This is where catch comes in, but it must be placed after the network call, not before, so you can retry or fall back.
Here is a simplified pattern:
fun observeProfile(userId: String): Flow<Profile> = flow {
emit(localCache.getProfile(userId))
val fresh = api.fetchProfile(userId)
localCache.saveProfile(fresh)
emit(fresh)
}.catch { e ->
// Log and re-emit cache if available, or throw
val cached = localCache.getProfile(userId)
if (cached != null) emit(cached) else throw e
}This is a starting point, but it has issues. The cache is fetched twice on error, and the Flow does not handle cancellation well if the network call takes too long. We can improve by using flowOn to shift the network call to Dispatchers.IO and cancellable to ensure the Flow responds to cancellation promptly.
Finally, collect the Flow in the ViewModel using stateIn to convert it to a StateFlow that survives configuration changes. Choose WhileSubscribed(5000) to keep the upstream active for a few seconds after the last collector disappears, preventing unnecessary restarts during rapid lifecycle changes.
Testing the Flow
To test this, use a TestDispatcher and mock the API and cache. Verify that the cache is emitted first, then the network result. Test the error case by making the API throw and confirming the cached value is re-emitted. Use TestCoroutineScope to control time and ensure cancellation works.
Tools and Environment Realities
Your tooling choices directly affect Flow quality. The Kotlin coroutines library is the foundation, but you also need the testing library (kotlinx-coroutines-test) and, for Android, lifecycle-aware collection via lifecycle-runtime-ktx or the repeatOnLifecycle API. Using liveData builder as a bridge between Flows and LiveData is a common pattern, but it adds complexity and can mask underlying issues.
For debugging, the IntelliJ coroutines debugger is invaluable. It lets you inspect the coroutine hierarchy, see which dispatcher each coroutine is running on, and detect leaked coroutines. However, it does not show Flow emissions directly. For that, you might need to add logging or use a debug operator like onEach { log(it) } temporarily.
Another practical consideration is the build system. If you use Gradle, ensure your coroutines version is consistent across modules. Mixing versions can cause subtle bugs, especially with the testing library. We have seen teams waste hours on flaky tests caused by mismatched kotlinx-coroutines-test versions.
Integration with Other Libraries
Flows often interact with Room, Retrofit, and other libraries. Room's Flow support is excellent, but you need to be aware that Room Flows are cold and will re-run the query on each collection unless you use stateIn or shareIn. Retrofit now supports Flow natively, but the error handling is different from callbacks — you must catch exceptions inside the Flow builder.
For dependency injection, Dagger or Hilt can provide scoped ViewModels, but ensure that the ViewModel's coroutine scope is properly cancelled when the ViewModel is cleared. This is usually automatic, but if you manually create coroutines with viewModelScope, they will be cancelled. However, if you pass a scope from outside, you risk leaks.
Variations for Different Constraints
Not every project needs the same Flow architecture. Here are three common variations and when to use them.
Variation 1: Single-Collector Screens
For simple screens with one data stream and no need for sharing, a cold Flow collected directly in the composable or activity is fine. Use flow { } with flowOn and collect in a lifecycle-aware manner. This is the simplest approach and works well for screens that are short-lived or have unique data.
Trade-off: If the same data is needed by multiple screens, you will duplicate work. Migrate to a shared Flow when that happens.
Variation 2: Shared State Across Screens
When multiple screens need the same state (e.g., user authentication status), use a StateFlow in a singleton repository. Use stateIn with WhileSubscribed to keep the upstream active while anyone is listening. This avoids redundant network calls and keeps state consistent.
Trade-off: The upstream is only active while there is at least one subscriber. If you need to keep it alive even when no one is listening (e.g., for analytics), use Eagerly sharing, but be mindful of resource usage.
Variation 3: Event Streams with Backpressure
For user actions or navigation events, use SharedFlow with a replay of 0. This ensures that events are not re-emitted on configuration change. Use extraBufferCapacity to handle bursts of events, but be aware that if the collector is slower than the producer, you might lose events. Consider using conflate or buffer with a custom strategy.
Trade-off: SharedFlows are harder to test because they are hot. You need to collect them in a separate coroutine and use TestDispatcher to control timing.
Pitfalls, Debugging, and What to Check When It Fails
Even with a solid design, Flows can misbehave. Here are the most common issues we see and how to diagnose them.
Context Loss: The Flow runs on the wrong dispatcher, causing main-thread violations or slow performance. Check that flowOn is applied at the correct point. Remember that flowOn only affects operators above it, not below. Use the coroutines debugger to verify the thread of each emission.
Cancellation Not Respected: The Flow continues emitting after the collector has cancelled. This often happens when using blocking calls inside flow { } without checking for cancellation. Add ensureActive() or use cancellable() operator. Also check that the scope used for launching the collection is properly cancelled.
Missed Emissions: A cold Flow restarts on each collection, so if you collect twice, you get two independent streams. If you expect sharing, use stateIn or shareIn. For hot Flows, missed emissions can happen if the collector subscribes after the event. Use replay or buffer as needed.
Error Swallowing: The catch operator only catches exceptions from upstream. If you place it too early, it might catch errors that should propagate. Always put catch as close to the collector as possible, and re-throw or handle explicitly.
Testing Flakiness: Tests that depend on time or real dispatchers are flaky. Use TestDispatcher and TestCoroutineScope to control time. Avoid using delay in production code that you want to test; instead, inject a time provider or use flowOn with a test dispatcher.
Debugging Checklist
- Is the Flow cold or hot? If cold, each collector gets its own stream; if hot, collectors share emissions.
- What dispatcher is each part of the Flow running on? Add
.onEach { log(Thread.currentThread().name) }to trace. - Is cancellation working? Add a
finallyblock in the Flow builder to confirm it completes. - Are errors being caught at the right level? Trace the exception stack.
Frequently Asked Questions and Next Steps
When should I use StateFlow vs LiveData? StateFlow is the Kotlin-native choice and works well with coroutines. LiveData is lifecycle-aware out of the box but does not support coroutines directly. In a coroutine-heavy project, prefer StateFlow for consistency, but use LiveData if you need to integrate with Java code or legacy components.
How do I handle retries in a Flow? Use the retry operator with a predicate that checks the exception type and a delay strategy. Be careful not to retry indefinitely; use a max attempt count. Also consider exponential backoff using delay.
What is the best way to combine multiple Flows? Use combine for combining the latest values of multiple Flows, or zip for pairing emissions one-to-one. For more complex scenarios, flatMapLatest is useful for switching to a new Flow based on emissions.
How do I make my Flows testable? Inject dispatchers and use TestDispatcher. Avoid global state. For cold Flows, collect them in a test coroutine and use toList to capture emissions. For hot Flows, use SharedFlow with a test collector.
Now, take these insights and audit your current codebase. Identify one Flow that has caused issues — maybe a network call that does not respect cancellation or a shared stream that duplicates work. Refactor it using the patterns above. Then, write a test for it. That single exercise will reveal more about your architecture than any guide can.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!