6 min read

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.

AndroidKotlinCoroutinesFlowConcurrency
HR

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.

Keep reading