Engineering

shareIn's replay count is a product decision, not a tuning knob

replay = 1 re-delivers the last value to every new collector.

MobileByteSensei 10 min read
kmpcoroutinesarchitecture

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.
  • replay is 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 = 1 for state and replay = 0 for events — and replay = 0 turned 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 = 0 still 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.

One week of sync failures against toasts shown, with a hundred and twelve distinct failures producing three thousand four hundred toasts at a median of nine per failure

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 re-delivery test applied to every member of the sealed type, with two values remaining true when re-delivered to a late arrival and one becoming false

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.

Four configurations measured, showing that halving the subscription rate halves the toasts proportionally while the replay change fixes it at an unchanged rate, and that a zero replay loses seventeen percent of failures

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.


  • 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.
  • Unit is 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".

Follow by RSS