DEV Community

Cover image for One Snackbar, Three Streams: Choosing Between StateFlow, SharedFlow, and Channel
Android Expert
Android Expert

Posted on Originally published at Medium

One Snackbar, Three Streams: Choosing Between StateFlow, SharedFlow, and Channel

The most misused trio in Android. This post gives a behavior matrix and a decision tree, with one real bug per type.

Your snackbar fires twice after rotation. Here’s the stream you chose wrong.

Picture a ferry-booking app. The user taps a seat, the server says “seat 14C is already taken”, and the ViewModel needs the screen to show a snackbar. One line of code, three plausible stream types, and each of them has a way to hurt you in production:

  • One swallows the second identical error, so the user taps again and nothing happens.
  • One replays the error after rotation, so the snackbar pops up twice.
  • One silently drops the event when you hand it over from a callback, so the user sees nothing at all.

All three types are hot, all three look interchangeable in a code sample, and none of them is interchangeable in behavior. This post walks through the contract of each, the bug that contract makes possible, and how to pick without guessing. The examples assume Kotlin coroutines 1.7+ and lifecycle-runtime-compose 2.6+.

1. Cold vs hot, in one picture

A cold Flow is a recipe. Nothing runs until somebody collects, and every collector triggers its own execution of that recipe. A hot stream is a running thing: it produces whether or not anyone is listening, and collectors attach to something already in motion.

cold-vs-hot

Two consequences matter for the rest of this post.

First, “late subscriber” is a first-class concept for hot streams. What a collector sees when it attaches is a design decision you make through the type and its configuration. Rotation, a fragment returning from the back stack, and the app going to the background all produce late subscribers.

Second, Channel is the odd one out. It is not a Flow; it is a coroutine primitive (a queue with suspending send and receive). receiveAsFlow() wraps it in a Flow facade, which is convenient and also the reason people forget that its collectors compete rather than share.

You can also turn a cold flow hot with stateIn and shareIn. One nuance: "hot" describes the shared stream, not necessarily its upstream. With SharingStarted.WhileSubscribed, the upstream cold flow stops when the last collector leaves, and restarts when one returns. We will use stateIn that way later for UI state.

2. StateFlow: a value, not an event

StateFlow has three defining traits:

  1. It always has a value. You must supply an initial one, and value is readable at any time.
  2. It conflates. A collector slower than the producer sees the latest value and misses the ones in between.
  3. It skips equal consecutive values. Assignment is compared with equals(); if the new value equals the current one, nothing is emitted.

For state, all three are exactly right. The screen only cares what is true now, so dropping stale intermediates is a feature, and re-rendering for an identical value is waste.

The bug: “show the same error twice”

class BookingViewModel(private val ferry: FerryApi) : ViewModel() {

    private val _error = MutableStateFlow<String?>(null)
    val error: StateFlow<String?> = _error.asStateFlow()

    fun reserve(seatId: String) {
        viewModelScope.launch {
            ferry.reserve(seatId).onFailure {
                _error.value = "Seat $seatId is already taken"
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.error.filterNotNull().collect { message ->
            Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

First tap: the snackbar appears. The user dismisses it and taps the same seat again. The server returns the same failure, the ViewModel assigns an equal string, and MutableStateFlow ignores the assignment because the new value equals the old one. No emission, no snackbar. From the user's side the button is dead.

stateflow-equal-skip

Wrapping the text in a type with a unique id makes consecutive values unequal, which cures the dropped duplicate. It does not cure the sibling bug that sits in the same code: a new collector (rotation, back stack, returning from the background) receives the current value immediately, so a message that was already shown is shown again. The root cause is the same in both cases. You modelled an event as state. We will fix it properly in section 6.

Two smaller StateFlow traps worth knowing:

  • Update from more than one coroutine with update { }, not read-modify-write on value. The lambda is retried on contention.
  • Equality on a big data class with a large list is a structural comparison on every assignment. Keep state classes lean, or make the heavy part stable.

For screen state, expose it like this:

val uiState: StateFlow<BookingUiState> = repo.observeSeats()
    .map { seats -> BookingUiState(seats = seats) }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = BookingUiState(),
    )
Enter fullscreen mode Exit fullscreen mode

WhileSubscribed(5_000) keeps the upstream alive for five seconds after the last collector leaves, long enough to ride through a configuration change, short enough to stop work when the user has really gone.

3. SharedFlow: broadcast with a configurable memory

MutableSharedFlow(replay, extraBufferCapacity, onBufferOverflow) is the general-purpose broadcaster. Every active collector receives every emission. Equal consecutive values are all delivered. There is no initial value and no "current value" concept.

sharedflow-knobs

Some behavior that is easy to get wrong:

  • No subscribers means no delivery. With replay = 0 (the default), an emission while nobody is collecting is gone. emit returns immediately and tryEmit returns true, so nothing even tells you it happened.
  • Overflow strategies need a buffer. DROP_OLDEST and DROP_LATEST throw IllegalArgumentException if replay + extraBufferCapacity is zero, because there is nothing to drop from.
  • tryEmit is only reliable with a drop strategy. Under SUSPEND with a full buffer it returns false, and callers routinely ignore the return value.
  • MutableSharedFlow(replay = 1, onBufferOverflow = DROP_OLDEST) behaves like a StateFlow without an initial value and without equality filtering. That is a useful mental model for what each type adds on top of the last.

The bug: replaying an event

The tempting repair for “the event got lost” is a replay cache:

private val _events = MutableSharedFlow<BookingEvent>(replay = 1)
val events: SharedFlow<BookingEvent> = _events.asSharedFlow()

fun reserve(seatId: String) {
    viewModelScope.launch {
        ferry.reserve(seatId).onFailure {
            _events.emit(BookingEvent.ShowError("Seat $seatId is already taken"))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

It does stop the loss. It also means that every new collector gets a copy of the last event. Rotate the phone and the new Activity attaches to a ViewModel whose cache still holds the old ShowError. Snackbar number two. The same thing happens without rotation when repeatOnLifecycle cancels the collector on STOPPED and starts a fresh one on the next STARTED. A replay cache is a state-like feature wearing an event-shaped costume.

Where SharedFlow is the right tool

When many independent listeners should each see each item and losing an item for an absent listener is acceptable:

class FareTicker {
    private val _fares = MutableSharedFlow<Fare>(
        replay = 0,
        extraBufferCapacity = 64,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val fares: SharedFlow<Fare> = _fares.asSharedFlow()

    fun publish(fare: Fare) {
        _fares.tryEmit(fare) // never suspends, never fails with DROP_OLDEST
    }
}
Enter fullscreen mode Exit fullscreen mode

A burst of fare updates cannot block the publisher, slow collectors see the most recent ones, and nobody replays stale prices. That is an event bus, and it belongs in an app-scoped object rather than a screen’s ViewModel.

4. Channel: one item, one consumer

A Channel is a queue. Each item goes to exactly one receiver. If nobody is receiving, items wait in the buffer (up to its capacity) instead of vanishing. That makes it the right shape for one-off events: "navigate here", "show this message once".

channel-fanout

The canonical setup:

sealed interface BookingEvent {
    data class ShowError(val message: String) : BookingEvent
    data class OpenReceipt(val bookingId: String) : BookingEvent
}

class BookingViewModel(private val ferry: FerryApi) : ViewModel() {

    private val _events = Channel<BookingEvent>(capacity = Channel.BUFFERED)
    val events: Flow<BookingEvent> = _events.receiveAsFlow()

    fun reserve(seatId: String) {
        viewModelScope.launch {
            ferry.reserve(seatId)
                .onSuccess { _events.send(BookingEvent.OpenReceipt(it.id)) }
                .onFailure { _events.send(BookingEvent.ShowError("Seat $seatId is already taken")) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Two identical errors in a row are two items, both delivered. A rotation in the middle leaves the items in the buffer for the next collector. Nothing is replayed after it has been received.

Pick receiveAsFlow, not consumeAsFlow

consumeAsFlow() cancels the channel when its collector is cancelled. With repeatOnLifecycle, the collector is cancelled every time the screen stops. The first backgrounding closes the channel for good, and the next send throws ClosedSendChannelException. consumeAsFlow can also be collected only once. receiveAsFlow() leaves the channel open and can be collected repeatedly.

The bug: trySend on a rendezvous channel

Channel<T>() with no arguments has capacity zero (rendezvous). A send suspends until a receiver arrives. A trySend succeeds only if a receiver is already waiting right now. Combine that with a callback API that cannot suspend:

private val _events = Channel<BookingEvent>() // RENDEZVOUS

init {
    paymentSdk.onDeclined { reason ->
        _events.trySend(BookingEvent.ShowError(reason)) // result ignored
    }
}
Enter fullscreen mode Exit fullscreen mode

If the screen is stopped, between collectors, or simply not suspended in receive at that instant, trySend returns a failed ChannelResult and the event is gone. Nothing throws. It works in every manual test because the app is in the foreground and the collector is parked in receive, then fails for the user whose card is declined while the app is backgrounded.

The fix is a buffered or unlimited channel, and not ignoring the result:

private val _events = Channel<BookingEvent>(Channel.BUFFERED)

paymentSdk.onDeclined { reason ->
    _events.trySend(BookingEvent.ShowError(reason))
        .onFailure { Log.w(TAG, "Dropped event: $reason", it) }
}
Enter fullscreen mode Exit fullscreen mode

Other things to know about Channel

  • Fan-out is intentional. If two collectors attach (two fragments, or a stale collector left behind after a lifecycle mistake), each item still goes to exactly one of them, and which one depends on which receiver is waiting at that moment. Do not rely on any particular split. One of those collectors may belong to a view that no longer exists.
  • Never feed it to collectAsState. State holders conflate, so a burst of events collapses to the last one.
  • Cancellation can strand one item. An element retrieved by a receiver that is cancelled before it can act on it is lost unless you pass onUndeliveredElement to the constructor. For snackbars this is usually acceptable; for "charge this card" it is not.
  • It is in-memory. A process death empties it.

Before moving on, here is the whole contract in one place:

behavior-matrix

5. Lifecycle-safe collection

A hot stream keeps producing when the UI is invisible, and a collector that keeps running while the UI is invisible does wasted work or touches views that are gone. You want the collector to exist only while the screen is at least STARTED.

Views and fragments

viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        launch { viewModel.uiState.collect(::render) }
        launch { viewModel.events.collect(::handle) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Details that bite:

  • collect on a hot stream never returns, so each stream needs its own launch inside the block. Two bare collect calls in a row means the second never runs.
  • In a fragment, use viewLifecycleOwner, not the fragment's own lifecycle. The fragment outlives its view across back-stack navigation. A collector tied to the fragment survives view destruction, then a second one starts when the view is recreated. You now have two collectors and, on a Channel, events that are randomly consumed by the one holding a dead binding.
  • launchWhenStarted is the old shortcut and is deprecated for a reason: it suspends the collector while stopped but leaves the upstream running. repeatOnLifecycle cancels and restarts it.
  • For a single stream with no sibling collectors, flow.flowWithLifecycle(lifecycle, STARTED) is a compact equivalent.

Compose

State uses the lifecycle-aware collector:

@Composable
fun BookingScreen(
    viewModel: BookingViewModel,
    snackbarHostState: SnackbarHostState,
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    LaunchedEffect(viewModel, lifecycle) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.events.collect { event ->
                when (event) {
                    is BookingEvent.ShowError ->
                        snackbarHostState.showSnackbar(event.message)
                    is BookingEvent.OpenReceipt -> { /* navigate */ }
                }
            }
        }
    }

    BookingContent(state)
}
Enter fullscreen mode Exit fullscreen mode

collectAsStateWithLifecycle is for state. Events are handled in an effect with repeatOnLifecycle. Note that showSnackbar suspends until the snackbar is dismissed, so events are handled one at a time and the rest wait in the channel, which is the behavior you want.

Why repeatOnLifecycle inside LaunchedEffect? A LaunchedEffect follows the composition, not the Android lifecycle. A screen that is stopped but still composed would keep collecting. Wrapping the collection in repeatOnLifecycle(STARTED) cancels the collector when the screen stops and starts a fresh one when it returns. (LifecycleStartEffect exists too, but it is for non-suspending start/stop side effects, not for collecting a flow.)

What happens to events while the collector is cancelled depends entirely on the stream, so do not mix the patterns by accident:

  • Channel: events sent while stopped wait in the buffer, and the new collector receives them in order. One edge remains: an item received at the instant of cancellation can be stranded unless you set onUndeliveredElement.
  • SharedFlow with replay = 0: events emitted while stopped are lost, because there is no subscriber.
  • SharedFlow with replay > 0: the latest event is delivered again to the new collector, which is the replay bug from section 3.
  • StateFlow: the new collector gets the current value, shown again unless the UI has acknowledged it (section 6, Fix B).

6. The “lost event on rotation” bug and three ways to fix it

The setup is a plain event bus in the ViewModel:

private val _events = MutableSharedFlow<BookingEvent>() // replay = 0
Enter fullscreen mode Exit fullscreen mode

The reservation call is in flight when the user rotates the phone. The Activity is destroyed, the ViewModel survives, the response arrives, and emit runs while no collector exists. There is nobody to deliver to, so the event is dropped. The new Activity then collects from a stream that has already moved on.

lost-event-rotation

You have three honest options.

Fix A: a buffered Channel

Everything from section 4. The event waits in the buffer until repeatOnLifecycle starts a new collector, then it is delivered once and is gone.

private val _events = Channel<BookingEvent>(Channel.BUFFERED)
val events: Flow<BookingEvent> = _events.receiveAsFlow()
Enter fullscreen mode Exit fullscreen mode

Trade-offs: single logical consumer, lost on process death, and you must not collect it from two places.

Fix B: model the message as state, and acknowledge it

Treat a pending message as part of UI state and let the UI tell the ViewModel when it has been handled. Nothing is ever “missed”, because state is always there for the next collector.

data class Message(val id: Long, val text: String)

data class BookingUiState(
    val seats: List<Seat> = emptyList(),
    val messages: List<Message> = emptyList(),
)

class BookingViewModel(private val ferry: FerryApi) : ViewModel() {

    private val _state = MutableStateFlow(BookingUiState())
    val uiState: StateFlow<BookingUiState> = _state.asStateFlow()
    private val nextId = AtomicLong()

    private fun post(text: String) {
        // Allocate the id outside update { }: its lambda can re-run on contention.
        val message = Message(nextId.getAndIncrement(), text)
        _state.update { it.copy(messages = it.messages + message) }
    }

    fun onMessageShown(id: Long) = _state.update { s ->
        s.copy(messages = s.messages.filterNot { it.id == id })
    }
}
Enter fullscreen mode Exit fullscreen mode
val state by viewModel.uiState.collectAsStateWithLifecycle()
val message = state.messages.firstOrNull()

LaunchedEffect(message?.id) {
    message?.let {
        snackbarHostState.showSnackbar(it.text)
        viewModel.onMessageShown(it.id)
    }
}
Enter fullscreen mode Exit fullscreen mode

The effect’s key is the id of the first pending message, so it only changes when that message is acknowledged. A second message that arrives while a snackbar is showing therefore cannot cancel it; it simply waits its turn. The unique id solves the “same error twice” problem from section 2, and the acknowledgement solves the replay. This is the approach Google’s architecture guidance leans toward. Trade-offs: the UI has to call back, the semantics are at least once (if rotation interrupts a snackbar before the ack, it shows again, which is arguably what you want), and it adds a little ceremony.

Fix C: SharedFlow with replay, then clear the cache

If you are stuck with a SharedFlow API, keep replay = 1 so a late collector still gets the event, and clear the cache once it has been handled:

private val _events = MutableSharedFlow<BookingEvent>(replay = 1)

fun onEventHandled() = _events.resetReplayCache()
Enter fullscreen mode Exit fullscreen mode

Trade-offs: this is a race by construction. An event emitted between handling and the reset is wiped, and with several collectors the first to call onEventHandled clears it for the rest. If you have exactly one consumer, you already have a Channel; use it. I would only reach for this when migrating legacy code.

My ranking: A for events that are fire-and-forget (navigation, a toast), B for anything the user must not miss and where you want it to survive recreation as a matter of design, C only as a stopgap.

7. Choosing: the tree and the table

If you remember one thing, make it this: ask what the data is before asking which type. State has a “current” answer. An event happens once. A broadcast goes to everyone. A job goes to one worker.

decision-tree

And the same answer arranged by the job you are doing:

decision-table

A work queue deserves one more line, since it is the use people forget Channel for:

class UploadQueue(scope: CoroutineScope, workers: Int = 3) {
    private val jobs = Channel<UploadJob>(capacity = 100)

    init {
        repeat(workers) {
            scope.launch {
                for (job in jobs) job.run() // each job goes to exactly one worker
            }
        }
    }

    suspend fun enqueue(job: UploadJob) = jobs.send(job) // suspends when full: backpressure

    /** Stop accepting jobs; workers drain what is queued, then exit their loops. */
    fun close() {
        jobs.close()
    }
}
Enter fullscreen mode Exit fullscreen mode

The fan-out behavior that was a trap for UI events is the entire point here. Bounded capacity gives you backpressure for free. Call close() when the owner is done: workers launched in a long-lived scope would otherwise stay suspended on for (job in jobs) forever. Because the queue lives in memory, anything that must survive process death belongs in WorkManager instead.

What I flag in code review

  • A StateFlow<String?> or StateFlow<Boolean> whose only job is to trigger a snackbar or navigation.
  • MutableSharedFlow(replay = 1) that is used for events.
  • Channel<T>() with no capacity argument, particularly next to trySend.
  • consumeAsFlow() anywhere near a lifecycle-scoped collector.
  • lifecycleScope.launch { flow.collect {} } without repeatOnLifecycle, or collection scoped to a fragment instead of viewLifecycleOwner.
  • An events flow passed to collectAsState or collectAsStateWithLifecycle.

Back to the ferry app. The seat-taken error is an event the user must see once, per occurrence, even across a rotation. That is a Channel (or acknowledged state), never a bare StateFlow<String?>, never a replayed SharedFlow. The type was never the hard part. Deciding which kind of data you are holding was.

📱 Go Beyond Using Jetpack Compose

If you’re building on Android, understanding what happens under the hood separates developers who use Compose from those who master it. I highly recommend “Mastering Jetpack Compose Internals”. It’s a deep, architecture-first walkthrough of the composition tree, the slot table, snapshot state, and the runtime that powers modern Android UI — capped off with a full case study building a real app called Mosaic.


Originally published on Medium.

Top comments (0)