Profiling Jetpack Compose Recomposition in Production: Composition Tracing, Stability Annotations, and the Metrics Pipeline That Found Our Hidden 60fps Drops
TL;DR
Excessive recompositions will wreck your frame rate in Jetpack Compose, and you won’t notice in dev builds. We combined Compose Compiler metrics at build time, runtime composition tracing, and a lightweight telemetry pipeline to find unstable classes triggering 3-8x more recompositions than necessary — the root cause of our intermittent 60fps drops. @Immutable and @Stable annotations fixed it completely. This post walks through exactly what we did.
The problem nobody sees in dev builds
The hardest Compose bugs to catch are the ones that only show up under real-world conditions — list scrolling with actual dataset sizes, deep navigation stacks, and state updates from multiple ViewModel streams firing at the same time.
Our team noticed frame drops in production crash-free session data, but local profiling showed perfectly smooth rendering. That gap between local and production behavior pointed to one thing: recomposition scope pollution from unstable parameter types.
Step 1: Compose Compiler metrics at build time
The Compose compiler can emit stability reports for every composable function. Enable it in your module-level build.gradle.kts:
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_metrics")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
This generates a few files per module:
| File | Purpose | What to look for |
|---|---|---|
*-classes.txt | Stability classification of all classes | Classes marked unstable |
*-composables.txt | Restartability and skippability per composable | restartable but NOT skippable functions |
*-composables.csv | Machine-readable metrics | Bulk analysis across modules |
The thing that matters most: a composable is only skippable if ALL its parameters are stable. One unstable parameter — a List<T>, a data class with a var property, or any class from an external module without Compose compiler processing — forces recomposition every single time the parent recomposes.
Our first audit turned up 34 composables marked restartable but not skippable across 4 feature modules. That told us where to look.
Step 2: Runtime composition tracing
Build-time metrics tell you what could recompose. Runtime tracing tells you what does. We built a lightweight composition counter using SideEffect:
@Composable
fun RecompositionTracer(tag: String) {
val count = remember { mutableIntStateOf(0) }
SideEffect {
count.intValue++
if (count.intValue > RECOMPOSITION_THRESHOLD) {
TelemetryLogger.logExcessiveRecomposition(
tag = tag,
count = count.intValue
)
}
}
}
Drop RecompositionTracer("FeedCard") inside any suspect composable. In debug builds, this logs to Logcat. In production, it feeds into our metrics pipeline — a simple ring buffer that batches recomposition events and ships them alongside frame timing data every 30 seconds.
The production data left no room for doubt. Our TransactionListItem composable was recomposing 7.2 times per visible frame during scroll, while stable equivalents recomposed once. That single composable was responsible for most of our dropped frames on mid-range devices.
Step 3: The stability annotation strategy
I think most teams get @Stable and @Immutable wrong: they slap them on reactively to silence warnings instead of designing for stability from the start.
What worked for us:
| Strategy | When to use | Example |
|---|---|---|
@Immutable | True value objects that never change after construction | @Immutable data class Currency(val code: String, val symbol: String) |
@Stable | Objects where Compose can trust .equals() for skip decisions | @Stable class UiState(val items: ImmutableList<Item>) |
ImmutableList / PersistentList | Replacing stdlib List<T> in composable params | kotlinx.collections.immutable |
| Wrapper classes | Stabilizing third-party types | @Immutable data class StableTimestamp(val epochMs: Long) |
The single highest-impact change was migrating from List<T> to ImmutableList<T> from kotlinx-collections-immutable in all UI state classes. The Compose compiler treats List<T> as unstable because it’s an interface with no immutability guarantee. Fair enough, honestly — the compiler can’t know you won’t mutate it.
// Before: unstable, triggers recomposition on every parent recompose
data class FeedUiState(
val items: List<FeedItem>,
val isLoading: Boolean
)
// After: stable, Compose can skip when equals() returns true
@Immutable
data class FeedUiState(
val items: ImmutableList<FeedItem>,
val isLoading: Boolean
)
The results
After rolling out stability annotations across our four main feature modules:
- Recomposition count per scroll frame: dropped from 7.2x to 1.0x on key list items
- Janky frame rate (>16ms): reduced by over 60% on median devices
- P95 frame render time: dropped measurably on our target mid-range hardware
Stability isn’t an optimization. It’s a correctness concern. If Compose can’t verify that your inputs haven’t changed, it has to recompose, and that cost adds up fast.
Incidentally, this profiling work happened during one of those long debugging sessions where I was reminded to actually stand up and move. HealthyDesk kept nudging me with break reminders and desk exercises — small interruptions that ironically made the session more productive.
CI integration
We now run Compose Compiler metrics on every PR. A simple diff script compares the composables.csv against the base branch and flags any composable that regresses from skippable to non-skippable. Catching instability at review time is far cheaper than finding it in production telemetry.
What to do right now
Enable Compose Compiler reports. Run a single build, grep for restartable functions that aren’t skippable, and you’ll immediately see your recomposition risk surface.
Replace List<T> with ImmutableList<T> in every UI state class. This single change eliminates the most common source of accidental instability in Compose codebases. I’m honestly surprised it’s not more widely discussed.
Ship a lightweight recomposition counter to production. Build-time analysis shows potential problems; runtime telemetry shows actual ones. Even a simple threshold-based logger will surface the composables that need attention before your users notice the jank.