Pavan Rangani

HomeBlogJetpack Compose Performance: Optimization Techniques for Smooth Android Apps

Jetpack Compose Performance: Optimization Techniques for Smooth Android Apps

By Pavan Rangani · March 16, 2026 · Mobile Development

Jetpack Compose Performance: Optimization Techniques for Smooth Android Apps

Jetpack Compose Performance Optimization

Jetpack Compose performance is the difference between an app that feels native and one that feels sluggish. While Compose is declarative and developer-friendly, its recomposition model can cause performance issues if you do not understand how it works under the hood. Fortunately, the runtime is also remarkably tunable, so once you grasp the underlying phases, most jank becomes both diagnosable and fixable.

This guide covers the optimization techniques that matter most in production — from controlling recomposition to tuning lazy layouts and profiling real-device performance. These patterns reflect lessons that teams shipping Compose apps to millions of users have repeatedly rediscovered, and they map directly to the guidance in the official Android performance documentation.

Understanding Recomposition

Recomposition is Compose’s mechanism for updating the UI when state changes. The runtime re-executes composable functions whose inputs have changed. The key performance insight: unnecessary recompositions are the #1 cause of jank in Compose apps. To be precise, every frame moves through three phases — composition, layout, and drawing — and the cheapest work is the work you skip entirely. Therefore, the goal is rarely to make recomposition faster; it is to make Compose recompose less often and over a smaller scope.

// BAD: Entire list recomposes when ANY item changes
@Composable
fun TaskList(tasks: List) {
    Column {
        tasks.forEach { task ->
            // This lambda captures 'tasks', causing recomposition
            // of ALL items when the list reference changes
            TaskItem(
                task = task,
                onToggle = { /* ... */ }
            )
        }
    }
}

// GOOD: Use LazyColumn with keys for minimal recomposition
@Composable
fun TaskList(tasks: List) {
    LazyColumn {
        items(
            items = tasks,
            key = { it.id }  // Stable key prevents unnecessary recomposition
        ) { task ->
            TaskItem(
                task = task,
                onToggle = { /* ... */ }
            )
        }
    }
}
Jetpack Compose performance profiling on Android
Understanding recomposition is key to optimizing Compose performance

Stability: The Foundation of Performance

Compose skips recomposition of a composable when all its parameters are stable and unchanged. A type is stable when Compose can compare its instances to detect changes. Consequently, making your data classes stable is the most impactful optimization:

// UNSTABLE: List and other collection types are unstable by default
data class TaskState(
    val tasks: List,        // List is unstable
    val filter: TaskFilter,
    val lastUpdated: Date         // Date is unstable
)

// STABLE: Use Immutable collections and stable types
@Immutable
data class TaskState(
    val tasks: ImmutableList,   // kotlinx.collections.immutable
    val filter: TaskFilter,
    val lastUpdated: Long             // Primitive = always stable
)

// Mark classes that won't change after creation
@Stable
class TaskRepository(
    private val api: TaskApi,
    private val db: TaskDao,
) {
    // Compose trusts @Stable — won't trigger recomposition
    // when this reference is passed to composables
}
// Add to build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
}

// Compose compiler reports — find unstable classes
// build.gradle.kts
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

Reading the Compiler Stability Reports

The compiler reports enabled above are the single best diagnostic tool most teams ignore. After a build, the generated *-classes.txt file labels every class as stable, unstable, or runtime, and the *-composables.txt file marks whether each composable is skippable and restartable. A non-skippable composable in a hot path is a red flag worth investigating first. Often the culprit is a single unstable parameter — frequently a plain List or an interface type from another module that Compose cannot prove immutable. Furthermore, a common surprise is that classes defined in modules without the Compose compiler are treated as unstable by default; in that case, annotating them with @Immutable or adding a stability-config file resolves it without restructuring your code. As a result, reading these reports turns guesswork into a targeted checklist.

Lambda and Callback Optimization

Lambdas are a common source of unnecessary recomposition. Every time a composable function executes, new lambda instances are created unless you stabilize them:

// BAD: New lambda created on every recomposition
@Composable
fun ParentScreen(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()

    TaskList(
        tasks = tasks.toImmutableList(),
        // This creates a new lambda instance every recomposition
        onTaskClick = { task -> viewModel.selectTask(task) },
        onDelete = { task -> viewModel.deleteTask(task) }
    )
}

// GOOD: Use method references or remember lambdas
@Composable
fun ParentScreen(viewModel: TaskViewModel) {
    val tasks by viewModel.tasks.collectAsStateWithLifecycle()

    // Method reference — stable, no reallocation
    val onTaskClick = remember { { task: Task -> viewModel.selectTask(task) } }
    val onDelete = remember { { task: Task -> viewModel.deleteTask(task) } }

    TaskList(
        tasks = tasks.toImmutableList(),
        onTaskClick = onTaskClick,
        onDelete = onDelete,
    )
}
Android app performance optimization tools
Profiling recomposition counts to identify performance bottlenecks

Deferring State Reads with derivedStateOf and Lambda Modifiers

One of the most effective recomposition fixes is to read state as late as possible. When a value is computed from frequently changing state but only matters when it crosses a threshold, wrap it in derivedStateOf so downstream composables recompose only when the derived result actually changes, not on every underlying tick. The classic example is a “scroll to top” button that should appear once the user scrolls past the first item:

@Composable
fun FeedScreen(listState: LazyListState) {
    // Without derivedStateOf, this recomposes on EVERY pixel scrolled.
    // With it, recomposition fires only when the boolean flips.
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }

    if (showButton) ScrollToTopButton(/* ... */)
}

Similarly, prefer the lambda-based Modifier.offset { } and graphicsLayer { } overloads for animated values. Because these defer the state read to the layout or draw phase, a changing offset skips recomposition entirely and only re-runs the cheaper later phases. Consequently, a smoothly animating element costs you draw work rather than a full composition pass every frame.

Lazy Layout Performance

LazyColumn and LazyRow are Compose’s equivalent of RecyclerView. Tuning them correctly is essential for smooth scrolling:

@Composable
fun OptimizedFeed(posts: ImmutableList) {
    LazyColumn(
        // Pre-compose items ahead of the visible area
        beyondBoundsItemCount = 3,
        // Content padding instead of Spacer items
        contentPadding = PaddingValues(16.dp),
        // Item spacing instead of Spacer between items
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        items(
            items = posts,
            key = { it.id },
            // Content type enables better recycling
            contentType = { post ->
                when {
                    post.hasImage -> "image_post"
                    post.isPinned -> "pinned_post"
                    else -> "text_post"
                }
            }
        ) { post ->
            // derivedStateOf for scroll-dependent calculations
            PostCard(
                post = post,
                modifier = Modifier.animateItem()  // Compose 1.7+
            )
        }
    }
}

// Avoid expensive operations during scroll
@Composable
fun PostCard(post: Post, modifier: Modifier = Modifier) {
    // Use SubcomposeLayout sparingly in lists
    // Avoid Modifier.graphicsLayer { } with complex calculations
    Card(modifier = modifier) {
        // Use AsyncImage with placeholder for smooth loading
        AsyncImage(
            model = ImageRequest.Builder(LocalContext.current)
                .data(post.imageUrl)
                .crossfade(true)
                .memoryCachePolicy(CachePolicy.ENABLED)
                .build(),
            contentDescription = post.title,
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(16f / 9f),
            contentScale = ContentScale.Crop,
        )
        Text(post.title, style = MaterialTheme.typography.titleMedium)
    }
}

Two details in that snippet do most of the work. First, a stable key lets Compose preserve item state across reorders and insertions, which prevents the whole list from re-composing when a single row changes. Second, contentType groups structurally similar items so the runtime can reuse their compositions during scrolling, much like view-type recycling in a RecyclerView. Without it, scrolling a heterogeneous feed forces fresh compositions for every visually distinct item.

Baseline Profiles: Performance You Get for Free

Beyond code-level fixes, one packaging step delivers an outsized win: shipping a Baseline Profile. Because Compose is a library compiled to bytecode, its hot methods start out interpreted and only get JIT-compiled after repeated use, which is why the first few seconds and the first scroll of a fresh install often feel slow. A Baseline Profile is a list of those hot methods that Android ahead-of-time compiles at install time. Benchmarks published by the Android team show meaningful improvements in startup and scroll jank — frequently in the 20 to 40 percent range for first-run scenarios — for essentially zero runtime code changes. Therefore, generating a profile with the Macrobenchmark library and bundling it should be standard practice before any release, not a last-resort optimization.

Profiling with Composition Tracing

// Enable composition tracing in debug builds
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            // Shows recomposition counts in Layout Inspector
            ComposeView.setContent {
                // Use Layout Inspector > Recomposition Counts
                // to identify hot spots
            }
        }
    }
}

// Custom recomposition counter for specific composables
@Composable
fun RecompositionTracker(name: String) {
    if (BuildConfig.DEBUG) {
        val count = remember { mutableIntStateOf(0) }
        SideEffect { count.intValue++ }
        Log.d("Recomposition", "$name: ${count.intValue}")
    }
}
Mobile app performance testing on devices
Always profile on real devices — emulator performance differs significantly

When NOT to Optimize, and Common Traps

It bears stating plainly: premature optimization wastes effort and can make code harder to read. If a screen already renders smoothly and the Layout Inspector shows stable recomposition counts, leave it alone. Equally important, always measure in a release build with R8 enabled, because debug builds disable optimizations and exaggerate slowness — chasing jank that only exists in debug is a classic dead end. Another frequent trap is wrapping trivial calculations in remember; the bookkeeping can cost more than simply recomputing a cheap value. In short, profile first, fix the proven hot spot, and verify the gain on a real mid-range device rather than a flagship emulator. For broader context across platforms, see our guide on mobile app performance optimization.

Key Takeaways

Optimizing render speed in Compose comes down to three principles: make your data stable (use @Immutable, ImmutableList), minimize recomposition scope (derive state, defer reads, use keys), and profile on real devices in release builds. As a result, your Compose apps will render at a smooth 60fps even with complex UI hierarchies and large data sets.

Related Reading

External Resources

In conclusion, Jetpack Compose performance tuning is an essential skill for modern Android development. By applying the patterns and practices covered in this guide, you can build more robust, scalable, and maintainable systems. Start with the fundamentals, measure before you change anything, and continuously verify results on real hardware to ensure you are getting the most value from these approaches.

← Back to all articles