When Smooth Becomes Janky
At PhonePe, we serve over 500 million users. When we started adopting Jetpack Compose for new screens, everything looked beautiful in development. But the moment we pushed to production and saw real device telemetry, the picture changed.
Frames were being dropped. Scroll jank appeared on mid-range devices. And our ANR rate ticked up by 0.3% — which at our scale means millions of bad experiences.
Understanding Recomposition
The first thing I learned: recomposition is not free. Compose's mental model of "just describe your UI and let the framework figure it out" works great — until you realize the framework is re-running your composable functions far more often than you think.
// BAD: This creates a new lambda on every recomposition@Composable
fun UserCard(user: User) {
Button(onClick = { viewModel.onUserClick(user.id) }) {
Text(user.name)
}
}
// GOOD: Stabilize the lambda
@Composable
fun UserCard(user: User, onUserClick: (String) -> Unit) {
Button(onClick = { onUserClick(user.id) }) {
Text(user.name)
}
}
The Profiling Setup
Before fixing anything, we needed to measure. Here's our profiling toolkit:
- Compose Compiler Reports — Generated with
-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=.... These reports show which composables are skippable, restartable, and which parameters are stable. - Layout Inspector — Android Studio's layout inspector shows recomposition counts in real-time. Any composable recomposing more than its parent is a red flag.
- Custom trace sections — We added
trace("SectionName")blocks around critical composables to see them in Perfetto traces. - Production metrics — Frame timing histograms, jank rate by screen, and recomposition counts shipped via analytics.
The Top 5 Performance Killers We Found
1. Unstable Data Classes
Compose determines whether to skip recomposition by checking parameter stability. If your data class contains a List, Map, or any mutable type, the entire composable becomes non-skippable.
Fix: Use @Immutable annotation or kotlinx.collections.immutable.
2. Unnecessary State Reads
Reading state in a parent composable causes the entire subtree to recompose. We found screens where a scroll position state was being read at the top level, causing the entire screen to recompose on every frame.
Fix: Push state reads as deep into the tree as possible. Use derivedStateOf for computed values.
3. Heavy Composition in LazyColumn
Our transaction list had complex items with multiple nested composables. When scrolling fast, composition couldn't keep up.
Fix: Pre-compute display strings outside of composition. Use key() properly. Extract heavy computations into remember blocks.
4. Image Loading in Composition
We were triggering image loads inside @Composable functions, causing re-loads on recomposition.
Fix: Use Coil's AsyncImage with proper caching. Preload images for lists.
5. Animations Running During Scroll
Multiple concurrent animations while scrolling crushed frame budgets on low-end devices.
Fix: Disable or simplify animations when LazyListState.isScrollInProgress is true.
The Results
After 6 weeks of optimization:
- Jank rate dropped 73% on our main transaction screen
- P50 frame time went from 12ms to 7ms
- ANR rate returned to pre-Compose levels
- Recomposition count reduced by ~60% across all Compose screens
Key Takeaways
- Profile first, optimize second — Don't guess where the problem is. Compose compiler reports are your best friend.
- Stability is everything — Make all your UI model classes
@Immutableor@Stable. - Test on real devices — Pixel 7 doesn't jank. The ₹8,000 phone your users actually have does.
- Compose is still worth it — Despite these challenges, our development velocity increased 2x after adopting Compose. You just need to understand the performance model.