Compose Strong Skipping Mode Does Not Make Your Types Stable

Compose Strong Skipping Mode Does Not Make Your Types Stable
Strong Skipping Mode is one of the most misunderstood features in Jetpack Compose. A common belief is that enabling it makes all types stable, eliminating the need to think about stability entirely. This is wrong. Strong Skipping Mode does not change the stability of any type. Unstable types remain unstable. What changes is how the runtime handles unstable parameters during the skip check: instead of always re-executing the composable, the runtime compares unstable values using referential equality (===) and skips if the exact same instance is passed. This is a meaningful optimization, but it is not a replacement for understanding stability, and it does not prevent unnecessary recomposition in many common patterns.
In this article, you'll explore what Strong Skipping Mode actually changes at the compiler and runtime level, how the generated code differs for stable and unstable parameters, why referential equality does not help when new instances are created on every recomposition, how lambda memoization works differently under strong skipping, and the practical cases where stability still matters despite strong skipping being enabled.
The misconception
The misconception takes several forms. "Strong Skipping Mode makes everything stable." "You don't need @Stable or @Immutable anymore." "Stability is a solved problem now." These statements share the same misunderstanding: confusing the ability to skip with actual stability.
Stability is a property of a type. It means the compiler can guarantee that the value's observable state will not change without Compose being notified. Int, String, and @Immutable data classes are stable. List<T> (an interface that could be mutable), classes with var properties, and types from external modules without Compose compiler processing are unstable. Strong Skipping Mode does not change any of these classifications. A List<Item> is still unstable with strong skipping enabled. The $stable bitmask the compiler generates for each class is identical regardless of whether strong skipping is on or off.
What changes is the skip decision. Without strong skipping, a composable with any unstable parameter can never skip. With strong skipping, the composable can skip if the unstable parameter is the exact same instance as the previous composition.
What the compiler actually generates
The best way to understand the difference is to look at the code the Compose compiler generates. Consider a composable that takes an unstable parameter:
@Composable
fun Test(x: Foo) {
A(x)
}
Where Foo is an unstable class (e.g., it has a var property or is from an external module).
With Strong Skipping Mode (default in modern Compose)
The compiler generates a skip check using changedInstance():
@Composable
fun Test(x: Foo, %composer: Composer?, %changed: Int) {
%composer = %composer.startRestartGroup(<>)
val %dirty = %changed
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changedInstance(x)) 0b0100 else 0b0010
}
if (%dirty and 0b0011 != 0b0010) {
A(x, %composer, 0b1110 and %dirty)
} else {
%composer.skipToGroupEnd()
}
%composer.endRestartGroup()
}
The key is changedInstance(x). This method compares the current value of x with the previous value using !== (referential equality). If the same instance is passed, the composable skips. If a different instance is passed, even if it is structurally equal, the composable re-executes.
Without Strong Skipping Mode
Without strong skipping, the compiler sets mightSkip = false for any composable with an unstable required parameter:
if (
!FeatureFlag.StrongSkipping.enabled &&
isUsed && isUnstable && isRequired
) {
mightSkip = false
}
When mightSkip is false, the compiler does not generate the skip check at all. The composable always re-executes, regardless of whether the parameter value changed.
Stable parameters use structural equality in both modes
For comparison, a composable with a stable parameter like Int always uses changed() with structural equality:
@Composable
fun Test(x: Int, %composer: Composer?, %changed: Int) {
// ...
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
}
// ...
}
changed(x) uses != (structural equality). This means two Int values of 42 are considered equal even if they are different objects, which is the behavior you want for value types.
changed() vs changedInstance(): The runtime difference
The two methods in the Composer interface tell the whole story:
override fun changed(value: Any?): Boolean {
return if (nextSlot() != value) { // structural equality
updateValue(value)
true
} else {
false
}
}
override fun changedInstance(value: Any?): Boolean {
return if (nextSlot() !== value) { // referential equality
updateValue(value)
true
} else {
false
}
}
The only difference is != vs !==. Structural equality (!=) calls equals(). Referential equality (!==) checks if the two references point to the exact same object in memory.
This distinction explains both the power and the limitation of strong skipping. For a stable data class User(val name: String, val age: Int), two instances with the same name and age are structurally equal, so changed() returns false and the composable skips. For an unstable type under strong skipping, changedInstance() returns false only if the exact same object is passed, not a copy or a new instance with the same values.
When strong skipping does not help
Understanding the referential equality check reveals the cases where strong skipping provides no benefit:
New instances created every recomposition
@Composable
fun Screen() {
val items = listOf(Item("A"), Item("B"), Item("C"))
ItemList(items = items)
}
Every time Screen recomposes, listOf() creates a new List instance. Even though the content is identical, items !== previousItems because it is a new object. changedInstance() returns true, and ItemList re-executes. Strong skipping does not help here because there is no instance reuse.
The fix is the same as it would be without strong skipping: either use remember to preserve the instance or use an immutable collection type that is stable.
Data class copies
@Composable
fun UserCard(user: User) { /* ... */ }
@Composable
fun Screen(viewModel: MyViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
UserCard(user = state.user)
}
If UiState is recreated on every state emission (common with copy() in MVI patterns), state.user may be a new instance even when the user data has not changed. changedInstance() sees a different reference and triggers recomposition. If User were stable, changed() would use equals() and correctly skip.
Lambda captures creating new instances
@Composable
fun Screen(items: List<Item>) {
ItemList(
items = items,
onClick = { item -> handleClick(item) }
)
}
The lambda { item -> handleClick(item) } captures nothing unstable, so the compiler can memoize it and reuse the same instance. But if the lambda captures an unstable value:
@Composable
fun Screen(items: List<Item>, filter: Filter) {
ItemList(
items = items,
onClick = { item -> applyFilter(item, filter) }
)
}
The lambda captures filter, which is unstable. The compiler uses changedInstance(filter) to decide whether to create a new lambda instance:
val onClick = %composer.cache(%composer.changedInstance(filter)) {
{ item -> applyFilter(item, filter) }
}
If filter is a new instance on every recomposition, the lambda is recreated every time too. Strong skipping memoizes the lambda with referential equality on its captures, but this only helps when the captured values are the same instances.
Mixed stable and unstable captures
The compiler's golden test suite shows exactly how mixed captures are handled. When a lambda captures both a stable type (Bar) and an unstable type (Foo), the compiler generates different comparison calls for each:
// Source
@Composable
fun Test() {
val foo = Foo(0) // unstable
val bar = Bar(1) // stable
val lambda = { foo; bar }
}
The generated code uses changedInstance for the unstable capture and changed for the stable capture:
val lambda = %composer.cache(
%composer.changedInstance(foo) or %composer.changed(bar)
) {
{ foo; bar }
}
This shows that strong skipping does not treat all captures the same. Stable captures still get the benefit of structural equality comparison. Only unstable captures fall back to referential equality. If foo is a new instance but structurally equivalent to the previous one, the lambda is recreated. If bar is a new instance but structurally equal (via equals()), the cache is not invalidated for that capture.
When strong skipping does help
Strong skipping genuinely improves performance in two scenarios.
Singleton and object references
When a parameter is the same object instance across recompositions, referential equality catches it:
val config = AppConfig.getInstance() // singleton, always same instance
ConfigDisplay(config = config) // changedInstance returns false, skips
Without strong skipping, this composable would never skip because AppConfig is unstable (external type). With strong skipping, it skips because the same instance is passed.
State objects that are not recreated
@Composable
fun Screen() {
val scrollState = rememberScrollState() // same instance across recompositions
ScrollableContent(state = scrollState)
}
ScrollState is not annotated @Stable in its class definition, but the same instance is reused via remember. Strong skipping detects the same reference and skips.
What stability still gives you that strong skipping cannot
Stability enables structural equality comparison. This matters whenever you have equivalent values that are different instances:
- Data classes recreated via
copy\(\): stable data classes compare byequals(), strong skipping compares by=== - Collections rebuilt from the same data:
ImmutableListwith the same elements is structurally equal, a newListwith the same elements is referentially different - Values flowing through
StateFlow: each emission creates a new value instance, stability lets the runtime detect when the content has not actually changed
Strong skipping is a safety net that catches cases where the same instance happens to be reused. Stability is a guarantee that equivalent values are recognized as equal regardless of instance identity.
A mental model for the two mechanisms
It helps to think of stability and strong skipping as two layers of a decision tree. The first question the runtime asks is: "Is this parameter the same instance?" If yes, skip. This is the changedInstance() check that strong skipping enables for unstable types. The second question, asked only for stable types, is: "Is this parameter structurally equal to the previous value?" If yes, skip. This is the changed() check that stability enables.
Without strong skipping, unstable parameters never reach either question. The composable always re-executes. With strong skipping, unstable parameters get the first question but not the second. Only stable parameters get both questions, which is why stability still provides a stronger guarantee.
This two layer model clarifies when each mechanism matters. Strong skipping catches the easy case: the same object passed again. Stability catches the harder case: a different object with the same content. Both are needed for a fully optimized UI.
Conclusion
In this article, you've explored what Strong Skipping Mode actually changes at the compiler and runtime level. The compiler generates changedInstance() (referential equality) for unstable parameters instead of disabling skipping entirely, while stable parameters continue to use changed() (structural equality). Lambda captures follow the same pattern: unstable captures use changedInstance(), stable captures use changed(), and mixed captures use both. The types themselves remain unchanged. No stability classification is altered by strong skipping.
Understanding this distinction has practical consequences. If your composable receives a List<Item> rebuilt on every emission from a StateFlow, strong skipping does not prevent recomposition because each emission produces a new list instance. Making the type stable (via ImmutableList or @Stable annotation) lets the runtime compare by content and skip when the items have not changed. The same applies to data classes created with copy(), objects constructed inline, and lambda captures over unstable values.
The practical advice remains: use immutable collections for collection parameters, keep data class properties as val, annotate types with @Stable or @Immutable when the compiler cannot infer stability, use remember to preserve instances across recompositions, and use the Compose Stability Analyzer to detect instability in your composable parameters. Strong skipping is a valuable safety net that prevents the worst case scenario where a composable can never skip, but it is not a substitute for proper stability design.
As always, happy coding!

