DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Profiling Jetpack Compose Recomposition in Production

---
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`:

Enter fullscreen mode Exit fullscreen mode


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`:

Enter fullscreen mode Exit fullscreen mode


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>`:

Enter fullscreen mode Exit fullscreen mode


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.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)