Articles
Kotlin Flow Interview Questions: StateFlow, SharedFlow, and Missed Events
Practice Kotlin Flow interview questions with tested StateFlow conflation, SharedFlow replay, missing subscribers, buffering, and lifecycle scenarios.
Kotlin Flow interview questions often turn on one uncomfortable observation: the producer emitted a value, yet the screen never handled it. A correct answer starts with the delivery requirement, the presence of subscribers, and the flow's replay policy. Naming StateFlow or SharedFlow is only the beginning.
This guide uses a small JVM experiment to separate lost emissions, skipped intermediate state, and repeated replay after resubscription.
Evidence boundary: official Kotlin and Android documentation establishes API behavior. The traces below are original practice results from Kotlin 2.4.20, kotlinx.coroutines 1.11.0, JDK 21, and runTest virtual time. They are not Android-device measurements or reports of questions asked by a particular employer. Design recommendations are editorial guidance.
Start with the delivery contract
Imagine an order screen that displays current status and occasionally announces that a refund request was accepted. The current status and the announcement have different requirements. A returning screen needs the latest status. Whether it should repeat an announcement depends on what the announcement means and whether the user already handled it.
Before choosing an API, ask whether the value represents current state, a transient notification, or work that must eventually happen. Then ask who owns it, what happens while nobody is collecting, and what survives process death. Those answers prevent an attractive but incorrect blanket rule such as “SharedFlow is for events, so events cannot be lost.”
For our experiment, the values are deliberately simple integers. Seven is emitted before a collector exists; eight is emitted after the collector subscribes. Keeping payloads trivial makes the timing policy visible. Production order identifiers and acknowledgment records can be added only after the basic delivery behavior is understood.
Question 1: Can tryEmit succeed without delivering anything?
Yes. Our first branch creates this flow:
val events = MutableSharedFlow<Int>(
replay = 0,
extraBufferCapacity = 8,
)
check(events.tryEmit(7))
check(events.replayCache.isEmpty())
There is no subscriber when seven is emitted. We then start a collector, emit eight, and run the test scheduler. Its recorded list is [8], not [7, 8]. The successful Boolean did not promise that a future subscriber would receive seven.
The official SharedFlow reference distinguishes replay from capacity for lagging subscribers. Without subscribers, only the configured replay values are retained. Extra capacity does not become an offline mailbox.
That makes a precise debugging answer possible: “The producer accepted the call under this configuration, but the event had no recipient and no replay slot. I would inspect subscription timing before increasing the buffer.” Increasing eight to eight hundred would not repair this particular no-subscriber case.
The product consequence determines the fix. Losing a cosmetic animation may be acceptable. Losing the only record of a requested refund is not. The latter should not depend on a briefly active screen collecting an in-memory broadcast.
Question 2: Does replay fix one-time events?
Our second branch changes replay to one, emits seven and then eight before collection, and reads the first value from two separate subscriptions:
val events = MutableSharedFlow<Int>(replay = 1)
events.emit(7)
events.emit(8)
val firstVisit = events.first()
val secondVisit = events.first()
Both visits receive eight. This fixes a narrow late-subscriber problem but creates a different question: should a recreated screen handle that value again? Replay preserves recent data; it does not record that a specific UI already completed an effect.
In the order example, replaying current status is desirable. Replaying an instruction to open a confirmation dialog might be surprising after navigation. If the action is important, give it an identity and define acknowledgment rather than hoping a buffer setting supplies consumption semantics.
An acknowledgment also needs an owner. If two screens subscribe, does either acknowledgment finish the action for both? If the app dies after displaying the dialog but before recording acknowledgment, is repeating it acceptable? These are application decisions. They are not resolved by the number one in the constructor.
For an interview, explain this trade-off before proposing a complicated event wrapper. A wrapper that stores a consumed flag can itself introduce shared mutable state, unclear ownership, or accidental single-consumer behavior in a broadcast design.
Question 3: Why did StateFlow skip one?
Our StateFlow branch begins at zero. A scheduled collector first records zero. We then write one, write one again, and write two before allowing the collector to run:
val state = MutableStateFlow(0)
// Collector has recorded the initial zero.
state.value = 1
state.value = 1
state.value = 2
runCurrent()
The recorded sequence is [0, 2]. This is an observation from our controlled scheduling, not a guarantee that every collector always skips one. A collector allowed to run between writes could observe additional distinct values.
The StateFlow documentation describes current-value state with conflated updates. That is useful when a screen needs the latest status rather than a historical log of every intermediate assignment.
Do not use this trace to infer that the producer failed. The current value is two, which is exactly what our assertion checks. If the requirement is to account for every transition, such as every billable operation, choose a representation and storage policy that preserve those transitions instead of treating UI state as an event ledger.
The test therefore answers two different questions: what is the current value, and what sequence did this collector observe? Mixing those assertions is a common source of brittle tests and misleading debugging explanations.
Question 4: How does equality suppress updates?
A separate eager-collector branch begins with the string ready, assigns ready again, then assigns done. It records [ready, done]. Unlike the previous example, this isolates equality suppression from a slow collector skipping multiple updates.
For a data class, equality is also relevant to the fields included in the value. Prefer replacing an immutable state value with a new value representing the intended change. Mutating an object already held in state can make old and new observations difficult to distinguish and can bypass the notification you expected.
A useful review prompt is: “Which field changed, and does the state model's equality make that change observable?” If the answer requires disabling equality or adding a random identifier to every value, first reconsider whether you are modeling current state or trying to smuggle repeated events through it.
Repeatedly assigning an equal error value may intentionally leave the screen unchanged. If the requirement is to show each failed attempt separately, the attempt identity and user interaction belong in the model. That is a product-level distinction, not simply a coroutine tuning problem.
Question 5: What does WhileSubscribed actually stop?
We converted an upstream flow to state with a short, deliberately artificial policy:
val state = upstream.stateIn(
scope = backgroundScope,
started = SharingStarted.WhileSubscribed(
stopTimeoutMillis = 100,
replayExpirationMillis = 50,
),
initialValue = -1,
)
The upstream increments a start counter, emits 42, and waits for cancellation. Its finally block increments a stop counter. This lets the test distinguish stopping upstream work from resetting the stored value.
After the only subscriber leaves, we advance virtual time. The official WhileSubscribed API documents these as separate delays: the stop timeout follows the last subscriber, while replay expiration follows stopping.
| Time after last subscriber leaves | Our observed result | Meaning in this fixture |
|---|---|---|
| 99 ms | No upstream stop | The stop timeout has not finished. |
| 100 ms | One stop; value remains 42 | Upstream stopped, but state was retained. |
| 150 ms | Value becomes -1 | Replay expiration reset state to its initial value. |
| New subscription afterward | Second upstream start; value becomes 42 | Collection restarts the source. |
These tiny durations are test inputs, not production recommendations. With the default replay expiration, stopping upstream does not imply an immediate reset. Explain both settings whenever the interview question involves returning to a screen and unexpectedly seeing old or initial data.
Question 6: What changes when an Android screen stops collecting?
Android's StateFlow and SharedFlow guide explains lifecycle-aware collection. A screen can stop collecting while its state holder remains alive. That creates precisely the subscriber gap our JVM test isolates, even though the test does not simulate an Android Activity.
Consider a save operation that finishes during that gap. A replay-zero notification can disappear before the screen returns. Replaying the notification might deliver it later, but might also deliver it on another subscription. A state model that records the save outcome can let the returning UI render the result without requiring the original transient notification.
This is editorial architecture guidance: match the mechanism to the consequence of loss or repetition. If the work must survive process death, an in-memory StateFlow is not sufficient storage. If a backend action can be retried, also define idempotency and the authoritative record of completion.
Do not claim that lifecycle-aware collection itself guarantees delivery. It manages collection lifetime. The data source, retention policy, acknowledgment, and persistence still determine what is available when collection resumes.
Build a test that proves the intended timing
Our fixture uses runTest, backgroundScope, and a shared test scheduler. Background collectors are canceled by the test scope instead of leaving an infinite collection running. For the no-subscriber branch, an unconfined test dispatcher starts collection eagerly before eight is emitted.
For the conflation branch, we deliberately use the scheduled dispatcher and call runCurrent() at chosen points. That makes the absence of a collector run between assignments explicit. We do not add arbitrary real sleeps and hope the machine is slow enough to reproduce the result.
Android's Flow testing guidance also highlights active collectors for lazily or while-subscribed state. Reading .value alone is not proof that the upstream sharing policy has started. Start the collector required by the scenario, then assert the state or sequence that the contract actually promises.
The coroutines test API provides virtual-time control. Our boundary checks advance to 99, 100, and 150 milliseconds and run scheduled tasks explicitly. Keep those observations separate from claims about real Android scheduling, network latency, or thread interleavings, none of which this fixture measures.
Practice the surrounding engineering decisions
| Question | Connection to this guide |
|---|---|
| Design Android MVVM API Architecture | Place state, collection, and repository ownership. |
| Build a Compose Rating Card | Separate current UI value from user actions. |
| Debug a Concurrent Job Scheduler | Trace scheduling and cancellation assumptions. |
| Design a One-on-One Chat Service | Define loss, replay, and acknowledgment expectations. |
| Design Mobile Model Usage Quotas | Keep important accounting outside transient UI events. |
A strong Kotlin Flow answer predicts an observable result, explains the policy that produces it, and names the product consequence.