8 min read

Compose Performance: The Compiler Metrics Report

Two lines of Gradle make the Compose compiler write down every composable that can't be skipped and the parameter responsible. How to read the three report files, the four causes of instability with their fixes, and why the report is a hypothesis generator rather than a verdict.

Jetpack ComposeAndroidPerformanceKotlinGradle
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — The Compose compiler can emit reports and metrics describing every composable it compiled: restartable, skippable, and the inferred stability of each parameter. Turn it on with two lines in composeCompiler {}, build a release variant, and read *-composables.txt for the composables missing skippable, then *-classes.txt for the field that made a type unstable. Four causes cover almost everything: List<T> parameters, var properties, types from a module without the Compose compiler, and third-party types. Don't chase 100% skippable, never lie with @Immutable, and remember the report says what could recompose — a trace says what does.

Turning it on

With the Compose Compiler Gradle plugin (Kotlin 2.0+):

// app/build.gradle.kts
plugins {
    alias(libs.plugins.compose.compiler)
}

composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

Then build a release variant:

./gradlew assembleRelease
ls app/build/compose_reports/
# app_release-composables.txt
# app_release-classes.txt
# app_release-module.json

Release matters. Debug builds can report differently, and the numbers you act on should come from the configuration your users run.

Do this per module, not just :app — instability usually originates in a data module, and that's the module whose report will explain it.

Reading the three files

module.json — the scoreboard

{
  "restartableComposables": 412,
  "skippableComposables": 291,
  "readonlyComposables": 18,
  "totalComposables": 447,
  "unstableClasses": 23
}

291/412 skippable is about 71%. Useful as a trend line across releases; not useful as a target (see below).

*-composables.txt — the per-composable detail

restartable skippable fun MovieList(
  stable movies: ImmutableList<Movie>
  stable onClick: Function1<Movie, Unit>
)

restartable fun MovieRow(
  unstable movie: Movie
  stable modifier: Modifier
)

The second one is the finding. skippable is absent, and the reason is on the next line: unstable movie. Whenever MovieRow's parent recomposes, MovieRow re-runs — even if movie is the identical object — because the compiler can't prove that comparing it is meaningful.

Vocabulary, briefly:

  • restartable — Compose can re-execute this composable on its own, without re-running its parent. Almost everything is.
  • skippable — if every parameter is stable and equal to last time, Compose can skip it entirely. This is the one you want.
  • readonly — no state reads; free.

*-classes.txt — why a type is unstable

unstable class Movie {
  stable val id: String
  stable val title: String
  unstable val tags: List<String>
  <runtime stability> = Unstable
}

There it is: one field. tags is a List, List is an interface, and the compiler cannot know your implementation doesn't mutate underneath it. One field makes the class unstable, which makes every composable taking it unskippable.

The four causes, and what to do

1. List, Set, Map parameters

The most common by a wide margin. List<T> is a read-only interface, not an immutable type — the instance behind it may well be an ArrayList somebody mutates.

// build.gradle.kts
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0")
data class Movie(
    val id: String,
    val title: String,
    val tags: ImmutableList<String>,   // was List<String>
)

// at the boundary
val movie = dto.toMovie(tags = dto.tags.toImmutableList())

Convert once where the data enters your UI layer, not at the call site of every composable.

2. var properties

// unstable
data class Filter(var query: String, var page: Int)

// stable
data class Filter(val query: String, val page: Int)

If something needs to change, produce a new instance. That's the model Compose is built on, and fighting it costs recompositions everywhere downstream.

3. A module without the Compose compiler applied

The one that catches everyone, because nothing in your UI code hints at it.

If :core:model declares Movie but doesn't apply the Compose compiler plugin, the compiler has no stability information for it and infers unstable — even for a perfect immutable data class with only vals and primitives.

Two fixes:

// Option A: apply the plugin to the model module.
// It adds no runtime dependency for pure data classes.
plugins { alias(libs.plugins.compose.compiler) }
// Option B: a stability configuration file, for types
// you can't or shouldn't annotate.
// compose_compiler_config.conf
com.example.model.*
kotlinx.collections.immutable.*
java.time.LocalDate
composeCompiler {
    stabilityConfigurationFile =
        rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
}

Option B is also the right tool for third-party types — LocalDate, a BigDecimal, a model class from an SDK you don't control. You're asserting stability on their behalf, so verify the type really is immutable before adding it.

4. @Immutable and @Stable — a promise, not a hint

@Immutable
data class Theme(val colors: Colors, val typography: Typography)

These tell the compiler to trust you. They don't check anything.

Put @Immutable on something that actually mutates and Compose will skip a recomposition it needed — producing stale UI, which is a correctness bug and one of the more unpleasant ones to diagnose, because the code looks right and the screen is wrong.

Use them when the type is genuinely immutable and the compiler simply can't see it. Don't use them to make a number go up.

Two ways to misuse the report

Chasing 100% skippable

Skippability only pays when the composable is expensive and recomposes often. A one-line Text wrapper that re-runs is a handful of microseconds; making it skippable is work with no user-visible effect and a wrapper type nobody wanted.

Spend the effort on list items, anything inside a LazyColumn, and composables high in the tree with big subtrees under them.

Treating it as a measurement

The report is static analysis. It tells you what could recompose, not what does.

A composable that's unskippable but only appears once, on a screen entered twice a day, costs nothing. A skippable composable whose parameters genuinely change every frame recomposes anyway.

So use it as a hypothesis generator:

  1. Report → the candidate list, ordered by where you know the hot screens are.
  2. Layout Inspector recomposition counts → which of those actually recompose while you use the app.
  3. Perfetto or a Macrobenchmark → whether it costs enough frames to matter.
  4. Fix. Re-measure with the same benchmark.

Skipping step 2 is how people spend a day making MovieRow skippable and find that jank is unchanged because the real cost was an image decode.

What good looks like

Not a percentage. A short list of answers:

  • Do you know your skippable ratio and whether it moved this release?
  • Are the composables inside your LazyColumn items skippable?
  • Does *-classes.txt list any of your own domain models as unstable, and do you know why?
  • Have you checked whether your model module has the Compose compiler applied?

A team at 70% who can answer those is in better shape than one at 95% that reached it by annotating everything with @Immutable.

The rule

The report tells you where recomposition is possible; a trace tells you where it's happening. Use both, in that order. Two lines of Gradle buy you a written explanation of every unskippable composable and the exact field responsible — then let measurement decide which of those findings is worth your afternoon.

Keep reading