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 = { /* ... */ }
)
}
}
}
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,
)
}
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}")
}
}
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
- Jetpack Compose Android UI Guide
- Mobile App Performance Optimization
- Kotlin Multiplatform Mobile Guide
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.