Data flow in Kotlin Multiplatform (KMP) is one of those topics that looks straightforward on paper but turns slippery in practice. The official documentation gives you the building blocks—StateFlow, SharedFlow, Channel, Flow—but it rarely tells you which one to pick when your view model needs to survive a configuration change, or when your iOS counterpart expects a callback instead of a stream. This article takes a qualitative look at how teams actually reason about data flow architecture in KMP, drawing on patterns observed across multiple production projects. We'll avoid fabricated statistics and instead focus on the trade-offs, failure modes, and decision heuristics that emerge when you stop thinking in abstractions and start shipping code.
Who Needs This and What Goes Wrong Without It
If you're building a KMP app that shares business logic between Android and iOS, you've probably encountered the moment where a seemingly simple data flow pattern breaks on one platform. Maybe your Android ViewModel collects a StateFlow just fine, but the iOS SwiftUI view never updates. Or perhaps your repository emits a one-shot event, and the second subscriber misses it entirely. These are not bugs in the framework—they're mismatches between the mental model you brought and the actual contract of the reactive primitive you chose.
The audience for this guide is the intermediate KMP developer who has already shipped a shared module and is now trying to scale it. You know how to write a Flow, but you're not sure whether to use a Channel or a SharedFlow for WebSocket reconnection events. You've seen the 'hot vs cold' distinction, but you need to know what it means when your iOS consumer expects a delegate pattern. Without a structured approach, teams often end up with a mix of patterns that work in isolation but create subtle race conditions or memory leaks when composed.
What goes wrong? The most common symptom is the 'lost event' problem: a screen emits a navigation event, but the subscriber wasn't ready yet. Another is the 'stale state' problem: a StateFlow holds the last emission, so a new subscriber immediately gets old data that triggers an unwanted UI update. We've also seen teams overuse Channel for everything, only to discover that buffered channels accumulate events indefinitely, causing memory pressure on low-end devices. These issues are not theoretical—they appear in code reviews and crash reports, and they erode trust in the shared layer.
By the end of this article, you'll have a decision framework for picking the right data flow primitive for each scenario, plus practical patterns for handling lifecycle, platform bridging, and testing. You'll also learn what to check when the data flow breaks, so you can debug with confidence instead of guessing.
Prerequisites and Context: What You Should Settle First
Before we dive into patterns, let's level-set on the core concepts that every KMP data flow decision depends on. If you're comfortable with the difference between hot and cold streams, you can skip ahead, but we recommend reading this section anyway—it's where most mismatches originate.
Hot vs. Cold Streams in KMP
A cold stream (like a plain flow { } builder) starts producing values only when a collector subscribes, and it produces a separate sequence for each collector. A hot stream (like StateFlow or SharedFlow) starts producing independently of collectors, and multiple collectors share the same stream. The choice between hot and cold is the first fork in the road. Cold flows are great for one-shot operations like a network request or a database query. Hot flows are better for representing state that changes over time, like UI state or sensor readings. The mistake we see most often is using a cold flow where a hot one is needed, which leads to duplicate work or inconsistent state across collectors.
The Three Primitives: StateFlow, SharedFlow, Channel
StateFlow is a hot flow that always has a current value. It's ideal for representing UI state because new subscribers immediately get the latest value. SharedFlow is also hot but doesn't hold a value by default—you can configure replay and buffer. It's good for events that should be replayed to late subscribers (like a list refresh trigger) or for broadcasting to multiple collectors. Channel is a hot stream designed for one-shot events with backpressure support. It's the closest thing to a reactive queue, but it has a crucial difference: a Channel is not a Flow, though you can convert it to one using consumeAsFlow(). The catch is that a Channel can only be consumed once unless you use a BroadcastChannel (deprecated) or SharedFlow.
Platform Bridging Realities
On Android, you have the AndroidX Lifecycle library with repeatOnLifecycle and flowWithLifecycle, which handle lifecycle-aware collection. On iOS, there's no equivalent built-in lifecycle. You typically bridge flows using Kotlin/Native's suspend functions or callback-based APIs. This asymmetry means that a pattern that works perfectly on Android may cause issues on iOS if you don't account for the lack of lifecycle management. For example, a SharedFlow that emits on a background thread might not be observed on the main thread in iOS unless you explicitly switch dispatchers. We'll cover concrete bridging patterns in the core workflow section.
Core Workflow: Choosing and Implementing the Right Primitive
This section walks through a decision process you can apply to any data flow scenario in your KMP project. The goal is not to prescribe a single 'best' pattern but to give you a repeatable method for making the choice yourself.
Step 1: Classify the Data
Start by asking: is this data state or an event? State is persistent and has a current value—think user profile, list of items, toggle state. Events are transient and should be consumed exactly once—think navigation, snackbar messages, one-shot errors. If it's state, lean toward StateFlow. If it's an event, lean toward SharedFlow with replay = 0 or Channel. But be careful: some data looks like state but acts like an event. For example, a 'loading' flag might be state, but a 'loading started' event is not. The distinction matters because StateFlow will always replay the last value, so a new subscriber to a loading state might see 'loading = true' even though the loading finished moments ago.
Step 2: Determine the Number of Subscribers
If only one collector will ever consume the stream (e.g., a single ViewModel), a cold flow or Channel might suffice. If multiple collectors need the same emissions (e.g., two screens observing the same WebSocket feed), you need a hot flow. SharedFlow is the go-to for one-to-many broadcasting. StateFlow is also one-to-many but with the replay caveat. Channel is one-to-one by default—if you need multiple consumers, you must use a BroadcastChannel or convert to SharedFlow.
Step 3: Decide on Replay and Buffer
Once you've chosen the primitive, configure its replay and buffer behavior. For StateFlow, replay is always 1 (the current value). For SharedFlow, you can set replay to any non-negative integer. A common pattern is replay = 1 for events that new subscribers should see (like the last error message), or replay = 0 for events that should be delivered only to active subscribers. Buffer size matters for backpressure: if the producer emits faster than consumers can process, a buffer prevents dropped events. But an unbounded buffer can cause memory issues. We recommend starting with extraBufferCapacity = 1 and onBufferOverflow = DROP_OLDEST for most event streams, then tuning based on profiling.
Step 4: Implement the Bridge to iOS
For iOS, you typically expose flows as suspend functions or callback-based APIs. A common pattern is to create a collect function that takes a lambda and returns a Closeable (or a Kotlinx.coroutines.Job). The iOS side can then call this function and cancel the job when the view disappears. Example: fun observeMessages(onMessage: (Message) -> Unit): Closeable. Inside, you launch a coroutine that collects the flow and invokes the callback. This pattern works with both StateFlow and SharedFlow. For state, you might also expose a value property that iOS can read synchronously, but be aware that this breaks the reactive chain.
Tools, Setup, and Environment Realities
Your choice of data flow primitive is only half the battle. The tools and setup around it—coroutine scopes, dispatchers, testing utilities—determine whether the pattern holds up under load.
Coroutine Scopes: Where to Launch
Every flow collection needs a coroutine scope. On Android, the ViewModel's viewModelScope is the natural choice. On iOS, you need to create a scope tied to the view lifecycle, often using a CoroutineScope that is cancelled when the view controller is deallocated. A common mistake is using a global scope, which leads to leaks and unexpected behavior. We recommend creating a dedicated scope in each platform-specific layer and passing it to the shared module as a parameter.
Dispatchers: Main vs. Default
Flow emissions often happen on background threads (network, database). If you update UI state on a background thread, you'll get crashes on Android and undefined behavior on iOS. Always use .flowOn(Dispatchers.Default) for the producer side and .flowOn(Dispatchers.Main) for the consumer side if needed. But be careful: flowOn changes the context of the upstream, not the downstream. A better pattern is to use .catch {} and .onEach { } with explicit dispatcher switching inside the collector.
Testing Flows
Testing data flows in KMP requires kotlinx-coroutines-test. Use runTest with TestCoroutineDispatcher to control time. For StateFlow, you can use first() or toList() to collect emissions. For SharedFlow, be aware that toList() will never complete if the flow is infinite—use take() or a timeout. For Channel, consume the channel in the test and verify emissions. One pitfall: testing SharedFlow with replay = 0 requires you to collect before emitting, otherwise the event is lost. Always start the collection in a separate coroutine before triggering the emission.
Variations for Different Constraints
Not every project has the same constraints. Here are three common scenarios and how the data flow patterns shift.
Scenario A: High-Frequency Sensor Data
Imagine you're building a fitness app that streams accelerometer data at 50 Hz. Using StateFlow here would cause the UI to update 50 times per second, which is wasteful. Instead, use a Channel with a buffer and a sampling strategy. On the consumer side, collect in a loop and throttle updates to the UI using sample() or debounce(). The channel's backpressure handling ensures you don't lose data, but you control how much the UI sees.
Scenario B: One-Shot Navigation Events
Navigation events are the classic 'event' use case. A common pattern is to use SharedFlow with replay = 0 and extraBufferCapacity = 0 (or 1) to avoid replaying navigation to a new screen after a configuration change. However, if the user rotates the device, the event is lost. To handle this, some teams use a Channel and consume it in the ViewModel's onCleared() or use a SavedStateHandle to persist the pending event. Another approach is to use a sealed class that represents either state or event, and handle events in a lifecycle-aware way on Android.
Scenario C: Multi-Platform Caching Layer
When building a repository that caches data from a network source, you often need to emit cached data first, then fresh data. This is a perfect use case for flow { } with emit() calls. But if multiple screens observe the same repository, you want to avoid duplicate network calls. Here, shareIn() with a shared scope is your friend. Use flow.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000)) to share the upstream flow while keeping the network call active for 5 seconds after the last subscriber disappears. This pattern reduces network calls and keeps data fresh.
Pitfalls, Debugging, and What to Check When It Fails
Even with a solid design, data flow issues can slip through. Here are the most common failure modes and how to diagnose them.
Pitfall 1: The Lost Event
Symptom: a navigation or snackbar event never appears. Cause: the event was emitted before the collector started. Fix: use SharedFlow with replay = 1 if the event should be seen by late subscribers, or ensure the collection starts before the emission. For debugging, add a log statement in the producer and consumer with a unique ID to verify ordering.
Pitfall 2: Stale State on Recomposition
Symptom: after a configuration change, the UI shows old data briefly. Cause: StateFlow replays the last value, which might be stale. Fix: use a cold flow or a SharedFlow with replay = 0 if you don't want replay. Alternatively, add a timestamp to the state and ignore updates older than a threshold.
Pitfall 3: Memory Leaks from Unbounded Channels
Symptom: app memory grows over time. Cause: a Channel with a large or unbounded buffer accumulates events that are never consumed. Fix: always set a buffer capacity or use onBufferOverflow = DROP_OLDEST. For debugging, use Android Studio's Memory Profiler or Xcode's Instruments to track allocations.
Pitfall 4: Thread Safety on iOS
Symptom: crashes or UI inconsistencies on iOS. Cause: flow emissions happen on a background thread, but UIKit expects updates on the main thread. Fix: ensure you switch to Dispatchers.Main before updating UI. In the bridging function, use withContext(Dispatchers.Main) inside the callback.
FAQ: Common Questions in Prose
We've collected a few questions that come up repeatedly in code reviews and team discussions. They don't always have a single right answer, but here's how we think about them.
Should I use Channel or SharedFlow for one-shot events? Both work, but SharedFlow is more flexible because it supports multiple collectors and replay. Channel is simpler but limited to one consumer unless you use a BroadcastChannel (deprecated). We default to SharedFlow with replay = 0 and a small buffer, and only use Channel when we need backpressure control or a rendezvous semantics.
How do I handle lifecycle on iOS? The most robust pattern is to pass a CoroutineScope from the iOS side that is cancelled when the view controller is deallocated. In Swift, you can create a CancelHandle class that wraps a Job and call cancel() in deinit. Alternatively, use the KMP-NativeCoroutines library, which generates Swift async wrappers for your flows.
Can I use StateFlow for everything? Technically yes, but it's not advisable. StateFlow's replay behavior means every new subscriber gets the last value, which can cause unwanted side effects if that value represents a transient event. Also, StateFlow requires an initial value, which may not exist for all data. Reserve StateFlow for UI state and use other primitives for events and cold data.
How do I test a SharedFlow with replay=0? Collect the flow in a separate coroutine before emitting. Use launch inside runTest to start collection, then emit. Use take(1) or first() to get the emitted value. If the emission never arrives, check that the collector is active and that the flow is not cancelled.
What to Do Next: Specific Actions
Reading about patterns is one thing; applying them is another. Here are concrete steps you can take starting today.
First, audit your current KMP project. For each data flow in your shared module, classify it as state or event, count the subscribers, and check whether the chosen primitive matches the decision framework above. You'll likely find at least one mismatch—fix it and observe the impact.
Second, write a small test suite for your data flows. Start with the most critical ones: authentication state, navigation events, and real-time data streams. Use runTest and verify that events are delivered correctly under different timing scenarios. This will surface hidden assumptions about ordering and lifecycle.
Third, create a bridging utility for iOS that wraps flow collection in a cancellable handle. If you're using SwiftUI, integrate it with onAppear and onDisappear. If you're using UIKit, tie it to viewWillAppear and viewDidDisappear. This step alone prevents many of the platform-specific bugs we discussed.
Finally, share your findings with your team. Discuss the trade-offs you encountered and agree on a set of conventions for data flow in your shared module. Document the conventions in a short ADR (Architecture Decision Record) so that future developers can understand why certain choices were made. Patterns are only useful if they're consistently applied.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!