shareIn's replay count is a product decision, not a tuning knob
replay = 1 re-delivers the last value to every new collector.
TL;DR
shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)on a flow carrying both state and one-shot events. Every screen that resubscribed — on rotation, on tab switch, on returning from background — re-showed the last error toast, 3,400 times in a week for 112 distinct errors.replayis not a tuning knob for how much buffering you want. It is a statement about whether re-delivering the last value is meaningful, and that is a property of the data.- Splitting the flow by that question produced
replay = 1for state andreplay = 0for events — andreplay = 0turned out to be insufficient on its own for a different reason worth knowing. - What reading on buys: the test that distinguishes the two, why
replay = 0still drops events, and the measured resubscription rate that made 3,400 possible.
The case this post is about
One shared flow for a sync pipeline's output:
// commonMain/kotlin/sync/SyncFeed.kt
sealed interface SyncUpdate {
data class Progress(val done: Int, val total: Int) : SyncUpdate
data object Completed : SyncUpdate
data class Failed(val message: String) : SyncUpdate
}
class SyncFeed(repo: SyncRepo, scope: CoroutineScope) {
val updates: SharedFlow<SyncUpdate> = repo.syncEvents()
.shareIn(
scope,
started = SharingStarted.WhileSubscribed(5_000),
replay = 1, // "so a screen that opens mid-sync sees the progress"
)
}
// commonMain — the consumer
@Composable
fun SyncBanner(feed: SyncFeed) {
val update by feed.updates.collectAsStateWithLifecycle(initialValue = null)
when (val u = update) {
is SyncUpdate.Progress -> ProgressBar(u.done, u.total)
SyncUpdate.Completed -> Unit
is SyncUpdate.Failed -> LaunchedEffect(u) { toast(u.message) } // ← the problem
null -> Unit
}
}
The replay = 1 was added deliberately and for a real reason: a screen opening while a sync is in
progress should see the progress rather than nothing.
The symptom
Users reporting the same error toast repeatedly for a single failure:
# toasts shown per distinct sync failure, one week
distinct Failed updates emitted 112
toasts shown 3,400
median toasts per failure 9
worst case 41
Nine toasts per error, and the pattern in the logs was resubscription:
09:14:02 sync failed: "network unavailable" → toast
09:14:31 SyncBanner recomposed after rotation → toast (same message)
09:15:08 user switched tab and back → toast
09:18:44 app backgrounded and resumed → toast
…
Each resubscription received the replayed Failed and treated it as new. Which it is, from the
collector's point of view — a SharedFlow with replay = 1 is documented to deliver the last
value to every new collector, and that is what makes it right for progress.
And the resubscription rate is what turned a design flaw into 3,400 toasts:
# collectors created per session, SyncBanner
median 7
p90 23
Seven subscriptions per session for one visible banner — because collectAsStateWithLifecycle
resubscribes on every lifecycle start, and a tab switch is a lifecycle stop and start.

Why the obvious fix failed
The first fix was to remember which errors had been shown:
val shown = remember { mutableSetOf<String>() }
is SyncUpdate.Failed -> LaunchedEffect(u) {
if (shown.add(u.message)) toast(u.message)
}
Deduplication by message, in composition state — so it resets when the composable leaves, which is the same lifecycle event that caused the resubscription. It reduced the median from 9 to 4 and did not change the mechanism. It also suppresses a genuine second occurrence of the same error, which is the opposite bug.
The second fix was replay = 0:
.shareIn(scope, SharingStarted.WhileSubscribed(5_000), replay = 0)
The toasts stopped. And the progress bar stopped working for any screen that opened mid-sync, which
was the reason replay = 1 existed — so one real requirement was traded for another.
Worse, and this is the part I did not expect: replay = 0 also dropped errors. A Failed
emitted while zero collectors were attached went nowhere:
failures emitted while the banner was between subscriptions 19 of 112
toasts shown for them 0
Seventeen percent of errors were now silently lost, because WhileSubscribed stops the upstream
5 seconds after the last collector leaves and a SharedFlow has no buffer for a value with no
collectors. Trading 3,400 duplicate toasts for 19 missing ones is not obviously a win.
The third attempt was replay = 1 plus a sequence number so a collector could tell old from new:
data class Failed(val message: String, val seq: Long)
Which works and puts the deduplication burden on every consumer, forever, with nothing enforcing it — four consumers, and the fourth one added later would not know.
The actual mechanism
replay answers one question, and it is not "how much should I buffer".
replay = n re-delivers the last n values to every new collector. That is the entire semantic.
Whether it is correct depends on whether re-delivery is meaningful for the data:
value re-delivering it to a new collector means
Progress(3, 10) "the sync is at 3 of 10" — true, useful, idempotent
Completed "the sync has completed" — true, useful, idempotent
Failed("network") "a sync just failed" — FALSE. It failed once, earlier.
The first two are state: a description of how things are, which is as true for a late arrival as for an early one. The third is an event: an assertion that something happened, and re-asserting it to each new subscriber is a lie about the world.
So one flow carrying both cannot have a correct replay. Any value is right for one half and
wrong for the other, which is why both 0 and 1 produced a real defect.
And SharedFlow has no delivery guarantee for a collectorless emission. replay = 0 means no
buffer, so a value emitted with nothing attached is dropped — correct behaviour for a hot stream and
fatal for an event you must not lose. An event stream needs a Channel, which buffers and delivers
each value exactly once to whoever eventually receives it.
Which gives the split, and the reason it is two different types rather than two SharedFlows with
different replay counts:
state → StateFlow always has a value, replays it, conflates duplicates
events → Channel buffers, delivers once, nothing to replay

The fix
Two outputs with two types, split by the re-delivery question, and the consumer handling each in the way its type implies.
// commonMain/kotlin/sync/SyncFeed.kt
class SyncFeed(repo: SyncRepo, private val scope: CoroutineScope) {
/**
* STATE: how the sync currently is. Re-delivered to every new collector, which is correct
* and is what the original replay = 1 was for. A StateFlow rather than a SharedFlow with
* replay = 1 because it is the same thing with the guarantee in the type — it always has
* a value, so a collector never sees null and the composable needs no initial-value branch.
*/
val state: StateFlow<SyncState> = repo.syncEvents()
.scan(SyncState.Idle) { acc, e ->
when (e) {
is SyncUpdate.Progress -> SyncState.Running(e.done, e.total)
SyncUpdate.Completed -> SyncState.Idle
is SyncUpdate.Failed -> acc // a failure does not change the STATE
}
}
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), SyncState.Idle)
/**
* EVENTS: things that happened, once. A Channel rather than SharedFlow(replay = 0) for the
* reason replay = 0 was insufficient — a SharedFlow drops an emission with no collector,
* and 19 of 112 failures arrived while the banner was between subscriptions. A buffered
* Channel holds them and delivers each exactly once.
*/
private val _events = Channel<SyncEvent>(capacity = 32)
val events: Flow<SyncEvent> = _events.receiveAsFlow()
init {
scope.launch {
repo.syncEvents().collect { e ->
if (e is SyncUpdate.Failed) {
// trySend, not send: a full buffer must not suspend the sync pipeline, and
// 32 unread failures means something is wrong with the CONSUMER, which is
// worth reporting rather than back-pressuring into.
if (_events.trySend(SyncEvent.Failed(e.message)).isFailure) {
logger.warn("sync event buffer full; dropped: ${e.message}")
}
}
}
}
}
}
// commonMain — the consumer, where each type is handled as its type implies
@Composable
fun SyncBanner(feed: SyncFeed) {
// State: collected, re-delivered on resubscription, and that is correct — a banner opening
// mid-sync shows the progress, which is the requirement replay = 1 existed for.
val state by feed.state.collectAsStateWithLifecycle()
if (state is SyncState.Running) ProgressBar(state.done, state.total)
// Events: collected once each. A resubscription receives nothing it has already seen,
// because a Channel has nothing to replay.
LaunchedEffect(Unit) {
feed.events.collect { e -> when (e) { is SyncEvent.Failed -> toast(e.message) } }
}
}
And the output it produced, one week:
$ ./tools/sync-toast-probe.sh --days 7
before after
distinct Failed updates 112 104
toasts shown 3,400 104
median toasts per failure 9 1
failures with no toast at all 0 0
collectors created per session (p90) 23 23 ← unchanged; the cause was replay
One toast per failure, none lost, and the resubscription rate is unchanged — which is the point: the fix was not to subscribe less.
How we proved it
That replay, not resubscription, was the cause — because the resubscription count looks like the problem and reducing it was the tempting fix:
configuration collectors / session (p90) toasts per failure
as shipped 23 9
+ lifecycle-aware collection tuned 11 5
+ replay = 0, same subscription rate 23 1 (and 19 lost)
+ the Channel split 23 1
Halving the subscriptions halved the toasts, which is a proportional improvement and not a fix. The replay change fixed it at an unchanged subscription rate.
That replay = 0 loses events. The measurement that ruled out the simple fix, and the thing I
had not predicted:
Failed emitted while zero collectors attached 19 of 112
delivered with SharedFlow(replay = 0) 0 of 19
delivered with Channel(capacity = 32) 19 of 19
Seventeen percent silently lost. WhileSubscribed(5_000) keeps the upstream alive for 5 seconds
after the last collector, and a tab switch plus a slow screen exceeds that.
That the re-delivery test sorts the data correctly. Applied to every member of the sealed type, because the split is only as good as the classification:
value re-delivered to a new collector says kind
Progress(3, 10) "the sync is at 3 of 10" → true state
Completed "the sync has completed" → true state
Failed("network") "a sync just failed" → FALSE event
One question, three unambiguous answers. Completed was the one we had to think about — it reads
like an event and is a terminal state, and the test settles it: telling a late arrival the sync
has completed is true.
That a sequence number pushes the problem outward. Counted, since it is the fix that keeps the single flow:
consumers of SyncFeed.updates 4
consumers that would need dedup logic 4
consumers added in the following quarter 2 (neither would have known)
That trySend rather than send is load-bearing. The fixture is a stalled consumer, which is
how a buffered channel becomes a back-pressure path into the sync pipeline:
$ ./gradlew :core:sync:allTests --tests '*FullBufferDoesNotStallSync*'
send: sync pipeline suspended after 32 unread events; no further progress
trySend: sync continued; 33rd event dropped with a warning
A sync pipeline that stops because a UI consumer is not reading is a worse failure than a dropped error notification.
What I cannot tell you is whether 32 is the right capacity. It is comfortably above any burst we have observed — the worst recorded was 6 unread events — and the drop-with-a-warning path has never fired in production, so the number is untested at its limit. A capacity that is too large would delay the warning that a consumer has stopped reading, which is the only signal that path provides.
And an honest consequence. SyncFeed now exposes two properties where it exposed one, and a
consumer has to collect both — so a screen that forgets events silently shows no errors, which is
the original bug in a new shape. There is no type-level way to require collecting both, and the
only mitigation is that SyncBanner is the single consumer of events and the others legitimately
want state alone.

What this generalizes to
A replay or buffer setting looks like a performance knob and is usually a statement about the meaning of the data: whether telling a late arrival about a value is true. State survives re-delivery because it describes how things are; an event does not, because it asserts that something happened and re-asserting it is false. When one stream carries both, no setting is correct — and the test that separates them is a single sentence spoken out loud: "re-delivering this says ___", and then checking whether that sentence is true.
Related
- StateFlow has no events — the same split arrived at from the conflation side, where the symptom is a lost repeat rather than a repeated one.
Unitis not "once ever" — the composition-entry lifetime that produced 23 subscriptions per session.
Keep reading
One post a week on shipping mobile software. No digests, no course pitch. Subscribe.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".