---
title: "Profiling Compose Recomposition: Finding Hidden 60fps Drops"
published: true
description: "A step-by-step workshop on using Compose Compiler metrics, runtime composition tracing, and stability annotations to detect and fix excessive recompositions killing your frame rate."
tags: kotlin, android, architecture, performance
canonical_url: https://blog.mvpfactory.co/profiling-compose-recomposition-finding-hidden-60fps-drops
---
## What We Will Build
In this workshop, we will set up a complete recomposition profiling pipeline for a Jetpack Compose app. By the end, you will have three things working together: build-time Compose Compiler metrics that flag unstable classes, a lightweight runtime composition tracer you can ship to production, and an annotation strategy using `@Immutable` and `@Stable` that eliminated 60fps drops in our real shipping app.
Let me show you a pattern I use in every project now — because the frame drops you cannot reproduce locally are the ones your users feel the most.
## Prerequisites
- Android project with Jetpack Compose (BOM 2024.x or later)
- Kotlin 2.0+ with the Compose Compiler Gradle plugin
- `kotlinx-collections-immutable` library
- Familiarity with `ViewModel` and Compose state
## Step 1: Enable Compose Compiler Metrics at Build Time
Here is the minimal setup to get this working. Add this to your module-level `build.gradle.kts`:
kotlin
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_metrics")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
Run a build, then check the generated files:
| File | What to look for |
|------|-----------------|
| `*-classes.txt` | Classes marked `unstable` |
| `*-composables.txt` | `restartable` but NOT `skippable` functions |
| `*-composables.csv` | 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: Add Runtime Composition Tracing
Build-time metrics tell you what *could* recompose. Runtime tracing tells you what *does*. Here is a lightweight composition counter using `SideEffect`:
kotlin
@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 a simple ring buffer that batches recomposition events 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: Apply the Stability Annotation Strategy
The docs do not mention this, but most teams get `@Stable` and `@Immutable` wrong — slapping them on reactively instead of designing for stability from the start. Here is what worked for us:
| Strategy | When to use |
|----------|-------------|
| `@Immutable` | True value objects that never change after construction |
| `@Stable` | Objects where Compose can trust `.equals()` for skip decisions |
| `ImmutableList` / `PersistentList` | Replacing stdlib `List<T>` in composable params |
| Wrapper classes | Stabilizing third-party types you cannot annotate |
The single highest-impact change was migrating `List<T>` to `ImmutableList<T>`:
kotlin
// Before: unstable, triggers recomposition on every parent recompose
data class FeedUiState(
val items: List,
val isLoading: Boolean
)
// After: stable, Compose can skip when equals() returns true
@Immutable
data class FeedUiState(
val items: ImmutableList,
val isLoading: Boolean
)
The Compose compiler treats `List<T>` as unstable because it is an interface with no immutability guarantee — and fair enough, the compiler cannot know you will not mutate it.
## The Results
After rolling out stability annotations across four main feature modules:
- Recomposition count per scroll frame: **7.2x → 1.0x** on key list items
- Janky frame rate (>16ms): **reduced by over 60%** on median devices
- P95 frame render time: dropped measurably on target mid-range hardware
## Gotchas
- **Stability is correctness, not optimization.** If Compose cannot verify your inputs have not changed, it *must* recompose. That cost compounds fast in scrolling lists.
- **External module classes are always unstable.** Any class from a module without Compose compiler processing needs a stable wrapper — the compiler has no visibility into those types.
- **CI regression catching is essential.** We run Compose Compiler metrics on every PR. A diff script compares `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.
- **Local profiling lies to you.** Real-world conditions — actual dataset sizes, deep navigation stacks, multiple ViewModel streams firing simultaneously — produce recomposition patterns you will never see on your development device.
## Conclusion
Enable Compose Compiler reports right now. Run a single build, grep for `restartable` functions that are not `skippable`, and you will 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. Then ship a lightweight recomposition counter to production, because build-time analysis shows potential problems while runtime telemetry shows actual ones.
Incidentally, this profiling work happened during one of those long debugging sessions where [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) kept nudging me with break reminders and desk exercises — small interruptions that ironically made the whole session more productive.
Now go find those hidden recompositions before your users find them for you.
Top comments (0)