Compose Compiler Metrics: Fix Recomposition Bugs Fast
Meta description: Learn to parse Compose compiler reports, identify stability inference failures, fix unstable classes, and reclaim your 16ms frame budget.
Tags: android jetpackcompose kotlin mobile architecture
TL;DR
The Compose compiler generates .txt metric files that most teams never read. Inside them are the exact composables recomposing unnecessarily — burning your frame budget. Enable the reports, learn to parse the output, fix your @Stable/@Immutable contracts, and you can cut recompositions substantially before touching any runtime profiling tool. In a mid-size e-commerce app I worked on (87 composables, heavy list-based UI), fixing three unstable data classes cut total recompositions per scroll gesture from 340 to 91 — a 73% reduction with no business logic changes.
What most teams get wrong
Most teams reach for the Layout Inspector or Perfetto before they’ve even looked at what the compiler already knows at build time.
The compiler makes a binary decision for every composable: skippable or not skippable. If a composable is not skippable, it recomposes every time its parent recomposes — regardless of whether its inputs changed. That cascades. One unstable class at the top of your tree can invalidate an entire subtree on every state write.
The scale of this surprises people. In a production app with 120 composables, I saw a single non-skippable screen-level composable generate over 300 unnecessary recompositions per second during a list scroll — 11ms of a 16ms frame budget, gone.
Enabling the compiler report
Add this to your build.gradle.kts:
// Requires Kotlin Gradle Plugin 1.8+ and Compose Compiler 1.5+ for the compilerOptions DSL.
// AGP 8+ deprecates project.buildDir — use layout.buildDirectory instead.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
freeCompilerArgs.addAll(
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.layout.buildDirectory.get().asFile.absolutePath + "/compose_reports",
"-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.layout.buildDirectory.get().asFile.absolutePath + "/compose_metrics"
)
}
}
After a build, look in build/compose_reports/ for four files: *-classes.txt, *-composables.txt, *-composables.csv, and *-module.json.
Reading the output
Open *-composables.txt. Every composable is annotated with its stability verdict:
restartable skippable scheme("[androidx.compose.ui.UiComposable]")
fun ProductCard(
stable product: Product
stable onClick: Function0<Unit>
)
restartable scheme("[androidx.compose.ui.UiComposable]")
fun CartRow(
unstable cartItem: CartItem
stable onRemove: Function0<Unit>
)
CartRow is restartable but not skippable. The compiler can’t skip it because CartItem is unstable. Every recomposition of the parent will recompose CartRow regardless of whether cartItem actually changed.
Skippable vs. restartable
| Status | Can Skip Unchanged Inputs | Recomposes on Parent Recompose | Notes |
|---|---|---|---|
| Restartable + Skippable | Yes | No | Target state for most UI composables |
| Restartable only | No | Yes | Unstable parameter is blocking skippability |
| Not restartable | No | Inline in parent | Compiler inference outcome for simple, side-effect-free composables — not a developer annotation target |
Non-restartable is a compiler decision, not something you annotate toward. The compiler infers it for composables that are cheap enough to inline — typically small utility or layout composables with no restart overhead worth tracking. You can’t and shouldn’t try to “achieve” non-restartable status; focus your stability work on moving composables from restartable-only into restartable-and-skippable.
Stability inference failures: the real culprits
Check *-classes.txt for the verdict on your data classes:
unstable class CartItem {
stable val id: String
stable val quantity: Int
unstable val price: BigDecimal
<runtime stability> = Unstable
}
BigDecimal is not in the Compose stability allowlist, so the entire class fails inference. In practice, the most common offenders are:
BigDecimal,Date,Calendar- Collections from
java.util(List,Map,Set— usekotlinx.collections.immutable) - Third-party model classes you don’t own
If you own the class, annotate it directly:
@Immutable
data class CartItem(
val id: String,
val quantity: Int,
val price: BigDecimal
)
If you don’t own it, add it to a stability config file instead:
// stability_config.conf
com.squareup.moshi.JsonClass
java.math.BigDecimal
Reference it in your Gradle config: stabilityConfigurationFile = rootProject.file("stability_config.conf").
The cascade effect is structural, not incidental
Take a CheckoutScreen composable receiving an unstable OrderSummary parameter. That screen hosts a PricingBreakdown, a DeliveryOptions list, and a ConfirmButton — none of which have changed. Because CheckoutScreen is non-skippable, all three children recompose on every parent invalidation. If OrderSummary is rebuilt from a StateFlow on every emission (common in ViewModels that map domain models inline), you’re triggering that cascade on every keystroke in a nearby search field sharing the same composition scope.
The compiler report shows you exactly which parameter is blocking skippability. The runtime profiler only shows you the symptom. Fix it at the source with @Immutable or a stable wrapper — same solution either way.
Three things to do now
-
Enable compiler reports in CI and diff
*-composables.txton PRs. Any newrestartable(non-skippable) composable in a feature branch should be a blocking comment, not a post-ship discovery. -
Audit
*-classes.txtfor every class that crosses a composable boundary. Anyunstableclass in a screen-level composable’s parameter list is a guaranteed performance regression at scale. Fix it with@Immutableor a stability config file before the feature ships. -
Set a recomposition budget per screen, not just a frame budget. Target fewer than 5 recompositions per second on idle screens in Perfetto. If your compiler report shows more than 20% of composables as non-skippable, you have structural stability problems — no amount of
remember {}will save you.