Compose Strong Skipping Mode Does Not Make Your Types Stable

skydovesJaewoong Eum (skydoves)||14 min read
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 by equals(), strong skipping compares by ===
  • Collections rebuilt from the same data: ImmutableList with the same elements is structurally equal, a new List with 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.

When stability checks are redundant

The flip side of the misconception is also worth addressing. Just as strong skipping does not replace stability, stability does not always add value either. There are cases where making types stable introduces overhead without meaningful benefit.

StateFlow already deduplicates emissions

StateFlow performs structural equality comparison internally. When you call emit(newValue) or update .value, StateFlow checks newValue == currentValue and suppresses the emission if they are equal. This means the value that reaches collectAsStateWithLifecycle() has already passed an equality check before Compose ever sees it.

If your UI state flows through a StateFlow and the types implement equals() correctly, the Compose stability check is a second comparison on values that are already known to be different. In this scenario, making the type stable adds a redundant equals() call at the Compose layer. The composable will re-execute regardless because StateFlow only emits genuinely new values.

This does not mean stability is useless with StateFlow. Stability still helps when a parent composable re-executes for a reason unrelated to the StateFlow emission (e.g., a sibling state changed), because the stable child parameters can skip via equals() even though the parent ran. But for the direct consumer of a StateFlow, the deduplication has already happened.

Structural equality can be expensive

Stability enables equals() comparison, and equals() is not free. For a data class with a List<Item> containing hundreds of elements, structural equality walks every element in the list. If the composable is going to re-execute anyway (because the data genuinely changed), the equals() comparison was wasted work.

This cost matters most for large, deeply nested data structures. A UiState with multiple lists, maps, and nested objects can have an equals() implementation that is more expensive than simply re-running the composable body. In these cases, using remember with a targeted key is often more efficient than relying on stability:

val processedItems = remember(items.size, filterKey) {
    items.filter { it.matchesFilter(filterKey) }
}

This avoids both the structural equality cost and the recomposition cost by memoizing the derived value with lightweight keys.

Derived values can be memoized without stability

When a composable transforms or filters data, the transformation result can be memoized using remember regardless of the input type's stability:

@Composable
fun FilteredList(items: List<Item>, query: String) {
    val filtered = remember(items, query) {
        items.filter { it.name.contains(query) }
    }
    LazyColumn {
        items(filtered) { ItemRow(it) }
    }
}

The remember call caches the filtered list and only recomputes when items or query changes (by referential equality on the keys). This works even though List<Item> is unstable. You do not need to make the list stable for memoization to work. The remember key comparison uses the same referential equality that strong skipping uses, so the two approaches complement each other.

The practical balance

The right question is not "should I make this type stable?" but "does stability add value for this parameter in this context?" If the value flows through StateFlow and the composable is the direct consumer, stability adds a redundant check. If the type has an expensive equals() and changes frequently, stability adds overhead. If the value can be memoized with remember using lightweight keys, that may be more efficient than structural equality on the full object.

Stability adds the most value when equivalent instances are created at different points in the composition tree, when parent recompositions pass unchanged values to child composables, and when the equals() cost is low relative to the composable body cost. Understanding these trade offs lets you apply stability where it helps and avoid it where it does not.

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 takeaway is nuanced. Stability is not always necessary: StateFlow deduplication, remember memoization, and the cost of equals() on complex types are all valid reasons to skip stability annotations in certain contexts. But stability is also not obsolete: it remains the only mechanism that lets Compose detect structurally equal but referentially different values, which matters whenever parent recompositions propagate unchanged data to child composables. Strong skipping is a safety net that prevents the worst case of never-skipping composables, not a replacement for understanding when and where each tool provides value. To identify where stability matters in your codebase, use the Compose Stability Analyzer to detect unstable parameters and trace recomposition patterns across your composable tree.

As always, happy coding!

Jaewoong (skydoves)