Blog My Happy PlaceGemma Lara Savill

Solving lost notifications in Compose with SharedFlow

By Gemma Lara Savill
Published at March 22, 2025

Solving

I've recently been working on an Android app's Jetpack Compose screen that receives and displays notifications to the user. The screen's state is managed by a StateFlow. For the notifications, I'm using a 'notification flow' – a series of sequential notifications driven by the ViewModel using a SharedFlow.

So, the ViewModel emits notifications, they are queued and emitted to the user one at a time. Once the user acknowledges a notification, it's then removed from the queue, and the next notification, if present, is shown to the user.

While building this, I encountered an interesting problem, and decided to write down my findings: when the ViewModel emits two notifications very quickly on startup and the screen isn't ready to receive, the first emission is lost. It's never collected. This is a common problem – the producer is faster than the consumer.

It's worth noting that the rapid emission of these notifications is actually a good indicator of the app's performance. The notifications are triggered by different data sources, and their simultaneous arrival suggests that parallel data fetching using coroutines is functioning correctly. This concurrent data access is crucial for a smooth user experience, as it minimizes loading times. However, this efficiency inadvertently created the challenge of lost notifications, highlighting the need for a robust notification handling mechanism.

Before diving into the SharedFlow solutions, I spent some time considering where to manage the notification queue. Options included the ViewModel or the Compose screen itself. I settled on the screen because the number of notifications was expected to be relatively small, and maintaining a clear separation of concerns, with the ViewModel managing screen state, seemed like the cleaner approach. This type of architectural consideration is an important thing to keep in mind when implementing any type of solution.

Why did I chose SharedFlow over StateFlow for my notification flow? The key difference lies in how they handle emissions. StateFlow, designed to hold a state, always provides the latest value to new collectors – perfect for UI states. SharedFlow, on the other hand, is built for event streams. Since my notifications are sequential events, not a persistent state, SharedFlow was the right fit. However, this flexibility comes with a caveat: if the consumer isn't ready, emissions can be lost, which brings us back to our initial problem.

Option 1: MutableSharedFlow's replay parameter

The first solution I tried: I used the replay parameter in MutableSharedFlow, which controls how many previously emitted values are replayed to new collectors.

  • replay = 0 (Default): With the default replay = 0, if a new collector starts collecting after an emission, it will not receive that emission, this is what was happening in my particular case.
  • replay = 1: With replay = 1, if a new collector starts collecting, it will receive the most recent emission, even if it happened before the collector started.
// ViewModel (Simplified)
val notifications = MutableSharedFlow<String>(replay = 1)

private fun sendNotification(message: String) {
    viewModelScope.launch {
        notifications.emit(message)
    }
}

This solved the problem! Now the screen shows the first notification emitted from the ViewModel, BUT when the user navigates away from the screen, maybe from a link within the notification itself, and then navigates back to the screen, the notification was shown again! That is no good, we can't replay notifications to the user that have already been delivered and acknowledged.

I am queuing the notifications so I can show one to the user at a time and remove them from this list once the user acknowledged it, but even now, when the last notification shown is acknowledged, and removed from the list, when the user navigates away and back, the last notification is back on the screen! Why? Because replay=1 will emit the last emission again. When the screen reloads, the collector reappears, so it gets the last emission again. This is not what I was looking for.

Option 2: Slowing the emission

Okay, so I reverted the SharedFlow replay value back to the default zero. No more repeated emissions.

Time for another approach: slowing down the producer. I tried adding a delay to the ViewModel's notification emission, just a tiny 10-millisecond pause.

// ViewModel (Simplified)
private fun sendNotification(message: String) {
    viewModelScope.launch {
        delay(10) 
        notifications.emit(message)
    }
}

And it seemed to work!

  • The first emission was shown, acknowledged, and removed from the queue.
  • The second notification appeared, was acknowledged, and vanished from the screen and list.
  • Navigation away and back? No extra notifications.

Perfect... or so I thought. My entire test suite went haywire.

The ViewModel unit tests were salvageable – a quick swap from runCurrent() to advanceUntilIdle() to skip the delay, and they were green. But the Robolectric Compose UI tests? Not so simple. Using composeTestRule.advanceUntilIdle() didn't seem to work. Furthermore, while a 10-millisecond delay might seem negligible, it introduced potential performance concerns, especially in scenarios with high notification frequency. Although, in my tests I did not see any performance hits, it is not a good practice to add unnecessary delays. This solution was starting to look like a bad idea.

I decided not to go with this solution, as fixing all my tests seems like overkill and not the correct direction to be heading. So I continued investigating.

Option 3: yield()

The final solution implemented was to introduce a yield() call within the SharedFlow producer.

This approach resolved the issue, and all tests passed successfully.

// ViewModel (Simplified)
private fun sendNotification(message: String) {
    viewModelScope.launch {
        yield()
        notifications.emit(message)
    }
}

The effectiveness of yield() stems from its function as a cooperative suspension point within coroutines. _yield() essentially tells the coroutine, 'Hey, pause here, give other coroutines a chance to run.' When yield() is invoked, the coroutine execution is suspended, allowing other coroutines to execute. In this context, inserting yield() into the producer created a brief suspension between notification emissions. This suspension provided the Compose screen with sufficient time to initialize its collector before subsequent emissions occurred, thereby preventing the initial emissions from being lost. In this particular case, this solution avoided the drawbacks associated with replay buffers and explicit delays, offering a stable and predictable outcome, making it suitable for this scenario.

Final thoughts

This yield() solution proved effective in this context, demonstrating that every project presents its own set of challenges. The process of debugging and refining solutions is deeply rewarding, especially when guided by a reliable test suite. I hope this exploration has provided useful insights into managing notification streams with SharedFlow. Keep it simple, and keep testing.