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.

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 are based on lessons learned from shipping Compose apps with millions of users.

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.

// 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")
}

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

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)
    }
}

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

Key Takeaways

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

Related Reading

External Resources

Scroll to Top