Skip to main content

Jetpack Compose Performance: Strong Skipping Mode & Layout Optimization

 There is nothing more frustrating in Android development than building a beautiful, declarative UI only to watch it stutter during a simple scroll. You’ve optimized your business logic, moved heavy operations to background threads, and yet the Android Profiler shows skipped frames.

For years, the "magic" of Jetpack Compose came with a hidden tax: Stability. If the Compose compiler could not prove a data type was immutable, it pessimistically assumed the data changed on every frame, triggering unnecessary recomposition.

With the arrival of Kotlin 2.0 and the Compose Compiler Gradle plugin, we have a new paradigm: Strong Skipping Mode. Combined with proper usage of derivedStateOf, we can virtually eliminate UI jank without boilerplate wrapper classes.

The Root Cause: Why "Jank" Happens

To fix performance, you must understand the Compose phases. Frame drops usually occur during the Composition phase, before Layout or Draw.

When a parent composable recomposes, it calls its children. Ideally, Compose should "skip" children whose arguments haven't changed. However, prior to Kotlin 2.0, Compose used strict rules for "Stability":

  1. Primitives and Strings were considered Stable.
  2. Interfaces and Collections (e.g., List<T>) were considered Unstable.
  3. Classes with var properties were Unstable.

If a Composable accepted a List<Article>, the compiler tagged it as "Restartable" but not "Skippable". Even if the list content was identical, Compose checked referential equality. If the list instance was different (common in MVI architectures where state is regenerated), the entire list UI recomposed.

This forced developers to wrap lists in ImmutableList or annotate classes with @Stable, polluting the domain layer with UI-specific code.

The Solution: Strong Skipping Mode

Strong Skipping Mode is a compiler configuration available in the Kotlin 2.0+ Compose compiler. It fundamentally changes two behaviors:

  1. Equality over Reference: Composables with unstable parameters can now be skipped if the objects compare equal via equals().
  2. Lambda Memoization: Lambdas inside composables are automatically memoized, preventing them from breaking the skip logic of their children.

Step 1: Enabling Strong Skipping

Ensure you are using the Kotlin 2.0 compiler. In your module-level build.gradle.kts, configure the Compose Compiler plugin.

// build.gradle.kts (Module level)
android {
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14" // or newer
    }
}

// If using KGP 2.0+ and the Compose Compiler Gradle Plugin:
composeCompiler {
    enableStrongSkippingMode = true
}

Note: In recent iterations of the Compose Compiler meant for Kotlin 2.0, this mode is becoming the default. Explicitly enabling it ensures consistent behavior.

Step 2: Optimizing State Reads

Strong Skipping solves the recomposition triggers, but we must also ensure we aren't reading state earlier than necessary.

The most common mistake is reading scroll offsets or list state directly in the composition scope. This forces a recomposition on every pixel scroll. We move these reads to the Layout or Draw phase using lambda modifiers.

Implementation: The High-Performance List

Below is a complete, rigorous implementation of a performant scrolling list. This code demonstrates derivedStateOf for thresholds and deferred state reads for animations.

The Data Layer

We use a standard Data Class. In previous Compose versions, the tags: List<String> would break skippability. With Strong Skipping, standard equals() makes this safe.

data class Article(
    val id: String,
    val title: String,
    val summary: String,
    val tags: List<String> // standard List, usually unstable
)

The Optimized Composable

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp

@Composable
fun OptimizedArticleFeed(
    articles: List<Article>,
    onArticleClick: (String) -> Unit
) {
    val listState = rememberLazyListState()

    // OPTIMIZATION 1: deriveStateOf
    // We only want to toggle the "Scroll to Top" button visibility when
    // the boolean result actually changes, not on every pixel scroll.
    val showButton by remember {
        derivedStateOf {
            listState.firstVisibleItemIndex > 0
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        LazyColumn(
            state = listState,
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            items(
                items = articles,
                // OPTIMIZATION 2: Explicit Keys
                // Always provide a stable key. This helps Compose track item moves
                // and prevents recomposing the wrong slots.
                key = { it.id }
            ) { article ->
                ArticleCard(
                    article = article,
                    onClick = { onArticleClick(article.id) }
                )
            }
        }

        // Overlay Button
        if (showButton) {
            FloatingActionButton(
                onClick = { /* coroutine launch scroll to top */ },
                modifier = Modifier
                    .padding(16.dp)
                    .align(androidx.compose.ui.Alignment.BottomEnd)
            ) {
                Text("Top")
            }
        }
    }
}

@Composable
fun ArticleCard(
    article: Article,
    onClick: () -> Unit
) {
    // With Strong Skipping, this Composable is skippable
    // even though 'article' contains a List (unstable type).
    Card(
        onClick = onClick,
        modifier = Modifier.fillMaxWidth(),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = article.title,
                style = MaterialTheme.typography.titleLarge
            )
            Spacer(modifier = Modifier.height(8.dp))
            
            // OPTIMIZATION 3: Deferring State Reads via Modifiers
            // Instead of changing the color in Composition (which triggers recomposition),
            // we do it in the Draw phase if we had dynamic color logic.
            // Here represents a standard static display.
            Text(text = article.summary)
            
            Row(modifier = Modifier.padding(top = 8.dp)) {
                article.tags.forEach { tag ->
                    SuggestionChip(
                        onClick = { },
                        label = { Text(tag) },
                        modifier = Modifier.padding(end = 4.dp)
                    )
                }
            }
        }
    }
}

Deep Dive: Why This Works

1. The Stability Paradigm Shift

Without Strong Skipping, OptimizedArticleFeed would see articles: List<Article> as unstable. Every time you passed a new List object (even with identical data), the entire LazyColumn would flag for recomposition.

With Strong Skipping enabled, the compiler generates code that compares the passed List using Intrinsics.areEqual (structural equality). If the content of the list is the same, recomposition is skipped.

2. Lambda Memoization

Notice onClick = { onArticleClick(article.id) }. Previously, this lambda was recreated on every recomposition because it captures article. This new lambda instance would cause ArticleCard to recompose, because lambdas are unstable by default.

Strong Skipping automatically wraps this lambda in a remember block. It effectively generates:

// Conceptual compiled code
val memoizedOnClick = remember(article.id, onArticleClick) {
    { onArticleClick(article.id) }
}

This ensures ArticleCard only recomposes when data actually changes.

3. Derived State and Phasing

In OptimizedArticleFeedlistState.firstVisibleItemIndex changes rapidly. If we wrote val showButton = listState.firstVisibleItemIndex > 0 directly in the function body, the scope would invalidate on every scroll event.

By wrapping it in derivedStateOf, the composition system subscribes to the result of the calculation (the boolean), not the raw inputs. The scope only invalidates when false becomes true or vice versa.

Common Pitfalls and Edge Cases

1. The equals() Performance Trap

Strong Skipping relies on equals(). If you have a data class containing a massive List with thousands of items, calling equals() on the main thread during recomposition checks can become expensive itself.

Fix: If your list is massive, continue using kotlinx.collections.immutable.ImmutableList. Strong Skipping will use reference equality for types it knows are immutable, avoiding the deep comparison cost.

2. Mutable Objects

Strong Skipping assumes that if a == b, the UI does not need to update. If you use a mutable object (e.g., ArrayList) and mutate it internally without changing the reference, equals() might still return true (depending on implementation), or the reference check might pass, but the UI won't update because Compose doesn't know the internal state changed.

Fix: Always use read-only interfaces (ListSet) in your Composable signatures and treat them as immutable.

3. Deferring Modifiers

Often developers try to animate opacity based on scroll. Bad:

val alpha = animateFloatAsState(...)
Box(Modifier.alpha(alpha.value)) // Reads in Composition

Good:

val alpha = animateFloatAsState(...)
Box(Modifier.graphicsLayer { this.alpha = alpha.value }) // Reads in Draw

Using graphicsLayer defers the read to the draw phase, bypassing the composition and layout phases entirely.

Conclusion

The era of manually annotating @Stable and writing wrapper classes is ending. By leveraging Strong Skipping Mode, you allow the Compose compiler to handle stability intuitively using structural equality. Combined with derivedStateOf to throttle logic and deferred reads in modifiers, you can achieve 120fps scrolling performance that feels native and responsive.