Why Performance Profiling Matters in Jetpack Compose
I've shipped 6 production Android apps on the Play Store, and I can tell you with absolute certainty: intuition is not a debugging tool. When I started working with Jetpack Compose, I made the exact same mistake I see junior developers make constantly—assuming my code was performant because it looked clean and ran fine on my flagship Pixel phone.
Then I hit production. Users with mid-range devices reported janky animations, frame drops during list scrolling, and battery drain that made no sense. The kicker? My MVVM architecture was solid. My Kotlin Coroutines were properly scoped. My state management looked textbook perfect.
The problem wasn't architecture. It was unnecessary recompositions happening 40+ times per second when they should happen 1–2 times. Without Android performance profiling, I never would have caught it.
Performance profiling in Android development isn't optional anymore. It's the difference between shipping apps users love and shipping apps users delete after 2 weeks.
Understanding Compose Compiler Metrics
Before you open Android Studio's profiler, you need to understand what Jetpack Compose is actually doing under the hood. The Compose compiler generates reports that tell you exactly which composables are skippable, which ones recompose too often, and which ones are causing cascading recompositions.
Enabling Compiler Metrics
Add this to your build.gradle.kts:
android {
kotlinOptions {
freeCompilerArgs += listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics",
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
)
}
}After building, check build/compose_metrics/ for two critical files:
- module-statistics.txt — Overall recomposition stats
- composable-metrics.txt — Per-composable breakdown showing which ones skip composition
In the metrics file, you're looking for composables marked as restartable and skippable. If a composable that should be skippable isn't, that's your smoking gun.
What the Metrics Actually Mean
When you see this in your metrics:
fun ProductCard(product: Product): Line 42
skippable: false
restartable: trueIt means this composable will recompose every single time its parent recomposes, regardless of whether product changed. That's often a red flag.
A properly optimized composable looks like:
fun ProductCard(product: Product): Line 42
skippable: true
restartable: trueSkippable means the Compose runtime can skip recomposition if the parameters haven't changed. This is what you want.
Essential Android Profiling Tools
Android Studio Profiler: Your First Line of Defense
Open View → Tool Windows → Profiler in Android Studio. Run your app and navigate to the screen that feels janky. Watch three metrics obsessively:
- Frame Rate — Should stay at 60 FPS (90 on 90Hz displays). Anything below 30 FPS feels laggy.
- Memory — Watch for steady climbs (memory leaks) or sudden spikes (allocation storms).
- CPU — If it spikes during idle time, background tasks are eating resources.
I regularly see apps with solid architecture but terrible CPU graphs because they're running database queries or JSON parsing on the main thread. The profiler makes it obvious instantly.
Compose Layout Inspector: Visualizing Recompositions
This is pure gold for Jetpack Compose performance debugging. Open Layout Inspector (Tools → Layout Inspector) and you'll see:
- Every composable in your hierarchy
- Which ones recomposed (highlighted in blue)
- How many times they recomposed
- What state triggered the recomposition
I caught a bug in AudioBook AI where a single state change was causing 47 composables to recompose when only 1 should have. The Layout Inspector showed me exactly which composable was holding the state poorly.
📖 Pro Tip
Enable "Show Recompose Counts" in the Layout Inspector settings. Numbers don't lie—if you see a number in the hundreds for a simple list item, you've found your bottleneck.
Baseline Profiles: Measuring Real User Performance
Baseline profiles tell the Android runtime which methods matter most, so it prioritizes compilation. I've seen baseline profiles reduce cold start times by 40%.
Add this to your app and generate profiles from real user journeys:
// In your BaselineProfileGenerator module
fun generateBaselineProfile() {
rule.measureRepeated {
// Trace your critical user paths
runOnUiThread {
// Navigate to product list
// Scroll through 20 items
// Tap on a product
// Trigger animation
}
}
}Baseline profiles capture what actually matters to users, not what matters in benchmarks. This is why they're so effective.
Real-World Case: Debugging AudioBook AI
AudioBook AI hit 50K+ users, and around 100K monthly active users, we started seeing complaints about stuttering during audio playback visualization. The visualizer was a Compose animation that showed frequency bars responding to real-time audio data.
Here's what I did:
Step 1: Profile First, Speculate Never
I opened the profiler on a mid-range device (crucial—this is where problems show up) and started playback. The CPU graph went ballistic. Frame rate dropped to 22 FPS.
Step 2: Check Compiler Metrics
I built with compiler metrics enabled and found that my visualizer composable—which should have been skippable—wasn't. It was recomposing on every single audio frame update.
Step 3: Identify the Culprit
The problem was this (simplified):
@Composable
fun AudioVisualizer(frequencies: List<Float>) {
Column {
frequencies.forEach { freq ->
VisualizerBar(height = freq) // ❌ Problem: Creates new lambda every recomposition
}
}
}
@Composable
fun VisualizerBar(height: Float) {
Box(modifier = Modifier.height(height.dp))
}The frequencies list was a new object reference on every state update, so the compiler couldn't prove VisualizerBar's parameters were stable. Solution:
@Composable
fun AudioVisualizer(frequencies: List<Float>) {
Column {
// ✅ Fixed: Use index-based iteration
repeat(frequencies.size) { index ->
VisualizerBar(
height = frequencies[index],
modifier = Modifier.animateItemPlacement()
)
}
}
}
@Composable
fun VisualizerBar(height: Float, modifier: Modifier = Modifier) {
Box(modifier = modifier.height(height.dp))
}Result? Frame rate jumped to 58–60 FPS. CPU usage dropped by 65%. The visualizer was now skippable, and individual bars only recomposed when their specific frequency value changed.
⚠️ The Stability Problem
Jetpack Compose's entire performance model depends on parameter stability. If you pass unstable types (mutable lists, local class instances) to composables, the compiler assumes they always change and can't skip recomposition. This is the #1 performance gotcha I see in production code.
Performance Profiling Best Practices
Profile on Real Devices, Not Emulators
Emulators run at different frame rates, have different memory constraints, and don't represent your actual user base. I always profile on a Pixel 5a (mid-range, ~$300 device) because that's closer to median users than a Pixel 9 Pro.
Establish a Performance Budget
Before optimization, decide what's acceptable:
- Frame rate: 60 FPS minimum (90+ FPS for scrolling lists)
- Memory: 100–150 MB for typical screen
- CPU: <30% during idle, <60% during interaction
Measure against these numbers consistently. Don't optimize randomly—measure, set targets, then optimize toward them.
Use Jetpack Compose's Built-in Tools
Compose already gives you remember, derivedStateOf, and rememberCoroutineScope for free. Use them. If your composable depends on derived state, derivedStateOf prevents unnecessary recompositions:
@Composable
fun ProductList(products: List<Product>, searchQuery: String) {
// ✅ Only recompute when products OR searchQuery actually change
val filtered = remember(products, searchQuery) {
products.filter { it.name.contains(searchQuery) }
}
LazyColumn {
items(filtered.size) { index ->
ProductRow(filtered[index])
}
}
}Monitor Performance in CI/CD
Don't wait for production to find performance issues. Integrate Baseline Profiles and simple frame-rate checks into your CI/CD pipeline. At Raybit, we fail builds if frame rate drops below 55 FPS on our test device during smoke tests.
Key Takeaways
- Profile first, speculate never — Use Compose Compiler Metrics and Android Studio Profiler to identify real bottlenecks, not assumed ones. Intuition fails 80% of the time.
- Parameter stability is everything — Jetpack Compose can only skip recomposition when it's certain parameters haven't changed. Unstable types force recomposition, destroying performance.
- Test on mid-range devices — Profile on devices your actual users have, not flagship phones. That Pixel 5a will expose performance issues your Pixel 9 Pro hides.
- Establish and measure against performance budgets — 60 FPS, 100–150 MB memory, <30% CPU idle. Don't optimize blindly; measure against real targets.
- Use the Layout Inspector aggressively — It shows you exactly which composables recompose and why. This visual feedback is invaluable for catching cascading recomposition bugs.