Flow Backpressure: buffer, conflate, collectLatest
Flow handles backpressure by suspending, which means a slow collector throttles the producer. buffer decouples them, conflate drops values before work starts, collectLatest cancels work already running — three different answers to what a dropped value costs.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Flow has no backpressure strategy because emit suspends: a slow collector throttles
the producer, by design. buffer() runs the two concurrently so the total is max(producer, collector)
instead of the sum — use it when every value matters. conflate() drops queued values before work
starts — use it when only the newest matters and the work is short. collectLatest cancels work
already in flight — same use case, but only when cancelling halfway is safe. StateFlow is already
conflated, and flowOn inserts a buffer for free.
Why there's no strategy to configure
RxJava made you choose a BackpressureStrategy because onNext can't block. Coroutines don't have that
problem: emit is a suspending function, so a producer that's faster than its collector simply waits.
flow {
repeat(100) { emit(load(it)) } // suspends here until collect returns
}.collect { render(it) } // 100ms each
Everything runs in one coroutine, sequentially. Producer 1ms, collector 100ms, 100 items → 10.1 seconds, and the producer spent almost all of it parked. That's not a bug, it's the default contract, and for most flows it's exactly right.
It stops being right in three specific situations, and each has its own operator.
buffer() — when every value matters
buffer splits the chain into two coroutines with a channel between them. The producer emits into the
channel and keeps going; the collector drains it.
flow { repeat(100) { emit(load(it)) } } // 10ms each
.buffer() // default capacity 64
.collect { render(it) } // 100ms each
Without: 100 × (10 + 100) = 11s. With: producer and collector overlap, so you pay
max(1s, 10s) ≈ 10s. The win is the overlap, and it's real but bounded — buffering never makes a
slow collector fast. If the collector is the bottleneck, buffering just relocates the queue.
Where it genuinely pays: bursty producers. A file read that yields 500 rows instantly and then waits on I/O, feeding a collector that does steady per-row work. Without a buffer the two take turns; with one, the burst fills the queue and the collector never idles.
Capacity and overflow are the knobs:
.buffer(capacity = 100)
.buffer(capacity = 10, onBufferOverflow = BufferOverflow.DROP_OLDEST)
An unbounded buffer (Channel.UNLIMITED) on a flow whose producer is genuinely faster than its
collector is a memory leak with a timer on it. If you can't name the bound, you don't want unlimited.
conflate() — when only the newest matters
conflate() is buffer(Channel.CONFLATED): keep one value, and when a new one arrives while the
collector is busy, replace it. The collector always finishes the item it started; the intermediates
are simply never delivered.
locationUpdates // 10 per second
.conflate()
.collect { drawMarker(it) } // 200ms per draw
The 10Hz stream becomes ~5Hz of drawing, with the newest position every time. Nothing was cancelled, nothing was half-done — some values just never got picked up.
This is the right default for UI driven by a high-frequency source: sensors, location, progress, scroll-derived state, download percentages. Rendering a position you're already past is pure waste.
Note the one that trips people up: StateFlow is already conflated. So is SharedFlow with
extraBufferCapacity = 0 and onBufferOverflow = DROP_OLDEST. Adding .conflate() to a StateFlow
does nothing but suggest to the next reader that something subtle is happening.
collectLatest — when the newest matters and the work is long
collectLatest looks like conflate and behaves very differently: on a new emission it cancels the
coroutine running the previous block and restarts it.
searchQuery.collectLatest { q ->
delay(300) // debounce — cancelled on the next keystroke
val results = api.search(q) // in-flight request cancelled too
_state.value = results
}
That's the ideal case: the previous work is now worthless, it's suspending, and cancelling it frees a
network call. mapLatest and flatMapLatest are the same idea as operators, and flatMapLatest is how
almost every search screen should be written.
Two ways it goes wrong.
1. The body has no suspension point. Cancellation is cooperative. A block that does CPU work with no
suspend call never checks for cancellation, so collectLatest cancels nothing and you get every value
processed to completion — plus the cost of the machinery.
// Cancels nothing. Ever.
flow.collectLatest { heavyPureComputation(it) }
// Cooperates
flow.collectLatest { heavy ->
withContext(Dispatchers.Default) {
chunks.forEach { ensureActive(); process(it) }
}
}
2. The body has a side effect that must complete. This is the landmine:
// The insert may be cancelled between two writes.
analyticsEvents.collectLatest { db.insert(it) }
Cancelling a partially applied write is how you get inconsistent local state that nobody can reproduce.
If losing the work is unacceptable, you wanted buffer(), not collectLatest.
The decision, in one table
| Situation | Operator |
|---|---|
| Every value must be processed | buffer() |
| Bursty producer, steady collector | buffer(n) |
| Only the newest matters, work is short and non-cancellable | conflate() |
| Only the newest matters, work is long, suspending, cancellable | collectLatest / flatMapLatest |
| Drop under load but keep the queue bounded | buffer(n, DROP_OLDEST) |
It's already a StateFlow |
nothing |
Read it as one question: what does a dropped value cost? Money or correctness → don't drop, buffer. Pixels → conflate. A request nobody is waiting for any more → cancel it.
Two side doors that add a buffer without saying so
flowOn inserts a channel at the dispatcher boundary, so it buffers as a side effect. Half the reports
of "adding buffer() made it faster" are really "I introduced a dispatcher boundary" — the
threading behaviour of Flow operators is its own tour.
callbackFlow and channelFlow are channel-backed by definition, and callbackFlow's trySend will
silently fail once the buffer is full. If you're bridging a listener that fires faster than you collect,
set the policy explicitly:
callbackFlow { ... }.buffer(1, BufferOverflow.DROP_OLDEST)
Measure before you tune
Each of these is one line, which makes them easy to sprinkle in on a hunch. Don't. onEach with a
timestamp will tell you in five minutes which side is actually slow:
flow
.onEach { Log.d("bp", "emit ${System.nanoTime()}") }
.buffer()
.onEach { Log.d("bp", "collect ${System.nanoTime()}") }
.collect { render(it) }
If the emit timestamps are the ones bunching up, no buffering operator will help — the producer is the problem and you need to fix it there.
The rule
Suspension is the backpressure. The operators are for when suspension is the wrong answer. Pick by
what a dropped value costs, not by which one you saw in a sample — and remember that collectLatest is
the only one of the three that can leave work half-finished.