Every Android codebase that uses Flow eventually grows a flatMap something. A map viewport triggers a trail query, a photo grid triggers thumbnail loads, a sync queue triggers uploads. The three flattening operators look interchangeable in a code review, and they are not. Swapping one for another changes what runs in parallel, what gets thrown away, what order the UI sees, and which bug you will be paged for.
This post puts flatMapMerge, flatMapConcat and flatMapLatest through the same input, shows exactly where the timelines diverge, and then walks through the failure modes that tend to survive code review. Examples come from a fictional hiking app called Trailhead.
The short version, as three mental models:
-
flatMapMergeraces. Every item starts work right away, and results are emitted in the order they finish, not the order they arrived. -
flatMapConcatqueues. One inner flow runs at a time, in arrival order. Everything behind it waits, including the upstream producer. -
flatMapLatestcancels. A new item kills the running inner flow. Only the freshest work gets to finish.
One input, three timelines
Instead of arguing in prose, let’s run the same upstream through all three. The upstream emits A, B, C, pausing 100 ms after each emit. Each item's inner work costs a different amount of time: 300, 150 and 200 ms.
import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.test.* import org.junit.Test private data class Probe(val id: String, val costMs: Long) private fun source(): Flow<Probe> = flow { emit(Probe("A", 300)); delay(100) emit(Probe("B", 150)); delay(100) emit(Probe("C", 200)) } private fun work(probe: Probe): Flow<String> = flow { delay(probe.costMs) emit(probe.id) } private suspend fun TestScope.trace(label: String, results: Flow<String>) { val start = testScheduler.currentTime results.collect { println("${label.padEnd(6)} @ ${testScheduler.currentTime - start} ms -> $it") } } @OptIn(ExperimentalCoroutinesApi::class) class FlattenLab { @Test fun sameInputThreeStrategies() = runTest { trace("merge", source().flatMapMerge { work(it) }) trace("concat", source().flatMapConcat { work(it) }) trace("latest", source().flatMapLatest { work(it) }) } } Because runTest uses virtual time, there is no wall-clock jitter, and the timings follow directly from the delays. This is the expected output:
merge @ 250 ms -> B merge @ 300 ms -> A merge @ 400 ms -> C concat @ 300 ms -> A concat @ 550 ms -> B concat @ 850 ms -> C latest @ 400 ms -> C Each trace call is an independent run. The helper rebases the virtual clock to zero before collecting, so the three strategies are deliberately executed one after another and still line up at t = 0 in the diagram. The concurrency being compared lives inside each operator, not between the runs.
Snippets in this post omit most imports. Duration literals such as 150.milliseconds or 400.milliseconds need kotlin.time.Duration.Companion.milliseconds.
Three things in that picture deserve a second look.
Merge reorders. B arrived second and finished first. If your downstream code assumes arrival order, it is already wrong.
Concat is slower than the sum of its parts suggests. The pure work adds up to 650 ms, yet the last result lands at 850 ms. The reason is that flatMapConcat collects each inner flow inline, inside the upstream collector. While A runs, the producer is suspended in its emit call, so the 100 ms pause after A starts only after A finishes. Concat does not just serialize the work, it serializes the producer. With a cold flow that is harmless. With a hot source, it becomes a failure mode we will get to.
Latest throws work away. A and B were cancelled before producing anything, and the dashed outlines show the work that never happened. That is the entire value proposition: if nobody wants those results, not computing them is a feature.
flatMapLatest: when only the newest answer matters
Trailhead’s map screen reloads trails whenever the camera settles on a new viewport. Results for a viewport the user has already panned away from are worthless, which is the textbook case for cancel-and-restart.
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.* import kotlin.time.Duration.Companion.milliseconds sealed interface MapState { data object Idle : MapState data class Loading(val box: BoundingBox) : MapState data class Ready(val box: BoundingBox, val trails: List<Trail>) : MapState data class Failed(val box: BoundingBox, val cause: Throwable) : MapState } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class TrailMapViewModel(private val repo: TrailRepository) : ViewModel() { private val viewport = MutableStateFlow(BoundingBox.WORLD) val state: StateFlow<MapState> = viewport .debounce(150.milliseconds) .distinctUntilChanged() .flatMapLatest { box -> repo.observeIn(box) .map<List<Trail>, MapState> { MapState.Ready(box, it) } .onStart { emit(MapState.Loading(box)) } .catch { emit(MapState.Failed(box, it)) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MapState.Idle) fun onCameraIdle(box: BoundingBox) { viewport.value = box } } Two details carry real weight here. The catch sits inside the lambda, so a failed query becomes a Failed state for that viewport instead of ending the stream. And debounce plus distinctUntilChanged stop jittery camera events from cancelling perfectly good work.
Failure mode 1: cancellation that nobody honors
Cancellation in coroutines is cooperative. flatMapLatest cancels the previous inner collection and, in the implementations I am familiar with, waits for it to finish unwinding before the next one starts. If the old work never checks for cancellation, the new viewport sits behind it.
// Looks harmless, but 200 ms of CPU work that never checks its Job // holds up the next viewport until it finishes. suspend fun rank(trails: List<Trail>): List<Trail> = withContext(Dispatchers.Default) { trails.sortedBy(::scenicScore) } // Cooperative version: check between units of work. suspend fun rank(trails: List<Trail>): List<Trail> = withContext(Dispatchers.Default) { trails .map { ensureActive(); it to scenicScore(it) } .sortedBy { it.second } .map { it.first } } Blocking I/O has the same problem. A raw File.readBytes() or a synchronous Call.execute() ignores cancellation; wrap it in runInterruptible(Dispatchers.IO) { ... } or use a suspending client API.
Failure mode 2: cancelling a write that already happened
Cancelling a read costs nothing. Cancelling a write is a different story, because the server may have applied the change before your coroutine noticed it was cancelled. The client then has no idea whether the edit landed.
// Risky: fast typing cancels requests that may already be committed server-side. drafts.flatMapLatest { d -> flow { emit(api.saveDraft(d)) } } // Safer: debounce, then queue, and let the server reject stale versions. drafts .debounce(400.milliseconds) .flatMapConcat { d -> flow { emit(api.saveDraft(d.id, d.version, d.body)) } } The rule of thumb is that flatMapLatest belongs on reads and on idempotent work. If a write must go through it, give the request an idempotency key.
Failure mode 3: restart thrash
Equal keys that pass through repeatedly will cancel and restart identical work, flickering a loading state each time. distinctUntilChanged() ahead of the operator is cheap insurance. Also remember that StateFlow already conflates equal values, but SharedFlow, combine outputs and Room-backed flows do not.
flatMapMerge: when throughput beats order
Trailhead’s photo grid prefetches thumbnails for the next screenful. The items are independent, so there is no reason to load them one by one.
@OptIn(ExperimentalCoroutinesApi::class) fun prefetch(ids: Flow<PhotoId>, cache: ThumbnailCache): Flow<Prefetch> = ids.flatMapMerge(concurrency = 4) { id -> flow { emit(cache.load(id)) } .map<Thumbnail, Prefetch> { Prefetch.Hit(id) } .catch { emit(Prefetch.Miss(id, it)) } } Notice that every result carries its own id. Since completion order is arbitrary, the consumer must never depend on position.
Failure mode 1: the stale result wins
This is the classic search-box bug, and merge makes it possible with a single wrong operator choice.
// "kit" can be overwritten by the slower response for "ki". queries .flatMapMerge { q -> flow { emit(api.search(q)) } } .collect { results -> _ui.value = results } If the screen shows one current answer, you wanted flatMapLatest. Use merge only when results are folded into a keyed collection.
Failure mode 2: unbounded fan-out
concurrency = Int.MAX_VALUE is tempting when you have 5,000 ids. The default limit is 16 (adjustable by a system property), and the parameter exists for a reason. With no bound you hammer a rate-limited API into 429s, and every in-flight coroutine holds its buffers while it waits. Pick N from what the downstream resource can really sustain, such as the HTTP client's per-host limits.
Failure mode 3: slot starvation
Each inner flow occupies one of the N slots until it completes. Flows that never complete will eat the slots one by one:
// Room's observe functions never complete. With 16 ids already collected, // the 17th id is simply never subscribed. No error, no log line. trailIds.flatMapMerge { id -> trailDao.observeTrail(id) } Either take a snapshot (trailDao.observeTrail(id).first()), or restructure into a single query with an IN clause that observes the whole set at once.
Failure mode 4: sibling collapse
An uncaught exception in any one inner flow cancels all in-flight siblings and ends the pipeline. This is why the catch in the prefetch example is inside the lambda, not after the operator.
flatMapConcat: when order is the contract
Trailhead lets hikers edit waypoints offline. Those edits must hit the server in the order they were made: renaming a waypoint and then deleting it is not the same as the reverse.
sealed interface SyncOutcome { data class Applied(val id: EditId) : SyncOutcome data class Rejected(val id: EditId, val cause: Throwable) : SyncOutcome } @OptIn(ExperimentalCoroutinesApi::class) fun drain(edits: Flow<Edit>, api: TrailApi): Flow<SyncOutcome> = edits.flatMapConcat { edit -> flow { emit(api.apply(edit)) } .map<Ack, SyncOutcome> { SyncOutcome.Applied(edit.id) } .retryWhen { cause, attempt -> val transient = cause is IOException && attempt < 3 if (transient) delay(500L shl attempt.toInt()) transient } .catch { emit(SyncOutcome.Rejected(edit.id, it)) } } Retries live inside the queue slot, so a retrying edit keeps its place in line, which is exactly the ordering guarantee you wanted.
Failure mode 1: head-of-line blocking
One request that hangs freezes every edit behind it. Enforce deadlines at the HTTP client, for example with OkHttp’s callTimeout, so a stuck socket surfaces as an IOException that retryWhen can see and the queue keeps moving.
Failure mode 2: the inner flow that never ends
This is the concat twin of slot starvation, with N = 1:
// The first id's observer never completes, so the second id is never processed. ids.flatMapConcat { id -> trailDao.observeTrail(id) } When you see a concat whose later items mysteriously never run, check whether an earlier inner flow is infinite.
Failure mode 3: hot upstream pressure
Because concat holds back its producer, a hot source feels it directly:
private val edits = MutableSharedFlow<Edit>() // no replay, no buffer fun onEdit(edit: Edit) { // Returns false while the collector is busy with a previous edit. // Ignoring the result silently drops the user's change. edits.tryEmit(edit) } With emit the caller suspends, which may stall UI code. With tryEmit the edit vanishes. Neither is acceptable for user data. A Channel consumed via receiveAsFlow() gives you a real queue, and persisting to an outbox table gives you a durable one.
Failure mode 4: the queue dies with the process
An in-memory concat pipeline is only as durable as the process hosting it. For edits that must survive swipe-away and process death, write them to Room first and let WorkManager drain them. Use flatMapConcat for the in-process ordering, not as the source of truth.
Side by side
Cross-cutting rules that prevent most incidents
Catch inside the inner flow. Every example above does it, because an exception that escapes any inner flow ends the whole pipeline. Convert failures into values (Failed, Miss, Rejected) where they happen, and the outer stream keeps living.
Ask whether you need a flatMap at all. If each item maps to exactly one value, map { suspendCall(it) } or mapLatest { ... } is simpler to read and reason about. The API docs for these operators make the same point: when a suspending call inside map is enough, prefer it. Reach for flattening when the inner work is genuinely a stream.
Mind the opt-in. In the API docs I consulted, the merge and concat families are marked @ExperimentalCoroutinesApi, and flatMapLatest has carried the same marker, so annotate with @OptIn(ExperimentalCoroutinesApi::class) and check your kotlinx.coroutines version when upgrading.
Test with virtual time. The lab above is a template. For ViewModel pipelines, collect with Turbine under runTest and assert on emission order and on which inner flows were cancelled. Behavior differences between these operators are easy to pin down in tests and miserable to debug in the field.
Remember the lifecycle. With stateIn(..., WhileSubscribed(5_000)), a flatMapLatest pipeline stops five seconds after the UI goes away and starts again, from scratch, when it returns. Make sure restarting is safe.
A decision guide you can keep
🔚 Closing thought
The operator names describe mechanics, but the choice is really about what the work means. Results you can discard point to flatMapLatest. Effects that must keep their order point to flatMapConcat. Independent work with a throughput goal points to a bounded flatMapMerge. When you cannot say which sentence your feature fits into, that is the signal to slow down before picking an operator.
References
- flatMapMerge, kotlinx.coroutines API documentation
- flattenConcat, kotlinx.coroutines API documentation
📱 Stop Guessing Whether Your Android Code Actually Works
If “we’ll add tests later” is a sentence your team says a little too often, I’d recommend “Mastering Android Testing” — a 35-chapter deep dive into why Android testing is uniquely hard (Activity/Fragment coupling, async complexity, tooling churn) and exactly how to fix it, layer by layer. It walks through architecting for testability, JUnit/Truth/MockK fundamentals, ViewModel and coroutine/Flow testing without flakiness, Compose UI testing and the semantics tree, Espresso and instrumented tests, Room/Retrofit data-layer testing with a full sync-conflict-resolution case study, Robolectric’s real limits, CI pipelines, flaky test triage, and how to build a testing culture that actually survives contact with a deadline.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.



