Before Strong Skipping Mode [1] became the default, it was widely considered best practice in Compose to prefer immutable collections (e.g., kotlinx ImmutableList<T>) over unstable collections like List<T> for stability and better skipping behavior.
With Strong Skipping Mode now being the default, the situation has changed. In most real-world screens, immutable collections don't automatically outperform unstable ones - in many cases they provide little to no performance benefit, and can even become pure overhead due to conversion and equality costs.
Case-by-Case Analysis
To make this discussion concrete, I'll compare ImmutableList<T> and List<T> across three common cases.
In most Android apps, data from Retrofit, Ktor, Room, or similar libraries usually comes as List<T>. So using ImmutableList<T> for Compose stability typically means calling toImmutableList().
With that in mind, this post assumes:
-
List<T>is not backed by aMutableList<T>that is mutated from elsewhere. -
ImmutableList<T>is created viatoImmutableList().
Also, this post only discusses Compose skippability, not the architectural benefits of immutable collections.
Case 1. The list never changes (always the same instance)
If your list never changes and you keep the exact same instance, List<T> and ImmutableList<T> behave almost the same in practice: both end up with the cost of an instance equality check (===).
Though ImmutableList<T> still has an O(N) conversion cost (e.g., toImmutableList()), it's usually negligible since you do it only once in this case.
Case 2. The list instance changes only when the content actually changes
In this scenario, using ImmutableList<T> adds extra work: you pay a conversion cost to build it, and you also pay an O(N) object equality cost (structural equality, == or equals()) when Compose compares the new parameter to the previous one. Since you need to recompose anyway, that extra work is pure overhead - and List<T> avoids both costs.
Case 3. The content doesn't change, but a new list instance is created frequently
This is the one case where ImmutableList<T> can actually help. If you keep recreating List<T> instances with the same content, Compose will treat the parameter as changed because the instance equality check fails. With ImmutableList<T>, Compose can use object equality to recognize "same content" and skip work higher up the composition tree.
The catch is the cost: equals() for lists is O(N), and you’re also paying the conversion cost upfront. If most of the UI work is already isolated in skippable leaf composables, these costs are sometimes harder to justify than just letting List<T> flow down and relying on skipping at the leaf — especially with lazy composables like LazyColumn.
And there's another catch: instance changes without content changes can sometimes be filtered upstream, turning Case 3 into Case 2 - for example, using StateFlow<T> in your ViewModel. Details are in the Appendix.
So even in this case, ImmutableList<T> isn't an automatic win.
Conclusion
If your concern is skippability, there’s no need to convert List<T> coming from your data sources — the old assumption that "unstable = always bad" no longer holds. Still, ImmutableList<T> can sometimes be worth the overhead for better skippability — and it may offer architectural benefits beyond what this post covers. So if you’re already using it, there's no reason to switch back to List<T> either. Don’t worry too much upfront. It's enough to optimize when you see a real bottleneck.
Remember: not every composable should be skippable. [2] And not every list needs to be ImmutableList<T>.
Appendix. Case-by-Case Details
Case 3–1. Lazy composables
With lazy composables like LazyColumn, it’s easy to overestimate what you save by skipping higher up. Even if the parent recomposes, actual work is still bounded by the visible range, rather than scaling with the total list size. In that setup, paying O(N) for equals() and conversion can outweigh the work saved by higher-level skipping.
Case 3–2. StateFlow<T> and upstream filtering
StateFlow<T> has distinctUntilChanged() built-in: if the new value equals the current one, it doesn't emit and keeps the existing instance. So if you call update { it.copy(list = ...) } with the same content and nothing else changed, StateFlow<T> won't emit - the original list instance stays intact. This effectively turns Case 3 into Case 2. Thus, in this scenario, List<T> saves a conversion cost and an object equality check cost as described in Case 2.
Note: StateFlow<T> doesn't eliminate Case 3 entirely. For example, if you call update { it.copy(list = newInstance, isLoading = false) } and only isLoading actually changed, StateFlow<T> will emit because T.equals() returns false - and the new list instance gets delivered anyway.
Top comments (1)
It would be nice to provide some actual benchmarks here. This is all logical but still purely theoretical. And I've seen R8 to make some theorical stuff completely turned around