Unseen Recomposition Costs in Jetpack Compose
How memory allocation data exposes hidden performance issues in Android UI
1The Discrepancy Discovery
While profiling a production Compose screen, the developer noticed conflicting data between Layout Inspector's recomposition counts and Memory Profiler's allocation tracking. The screen appeared calm in Layout Inspector but showed thousands of allocations in Memory Profiler, revealing hidden recomposition work that wasn't being properly reported by standard tools.
2Tools Telling Different Stories
Layout Inspector showed minimal recompositions for a simple BalanceScreen displaying transactions and promotions. Meanwhile, Memory Profiler revealed 14,873 allocations in doCompose-aFTINEg and 12,805 in recomposeToGroupEnd, with ComposableLambdaImpl invocations buried deep in call stacks. This stark contrast indicated significant unseen recomposition activity.
3The Allocation Tracking Approach
Using Android Studio's Track Memory Consumption feature filtered by App → Callstack, the developer scanned for compose runtime entries like group(), recomposeScopeImpl, and synthetic lambdas. This method captured all object creations during recomposition, providing a more complete picture than Layout Inspector's surface-level counters.
4Compose's Hidden Machinery
The investigation revealed that Layout Inspector only counts full composable scope recompositions, while Compose's engine performs numerous internal checks (re-evaluating remember blocks, re-instantiating lambdas, diffing parameters) that create allocations. These background operations don't trigger UI redraws but significantly impact performance.
5Two Major Problems Uncovered
1. Invisible re-evaluations: Code paths were re-running and allocating objects without triggering recomposition counts. 2. Misrepresented stability checks: Composables appeared normal in Layout Inspector while Compose defensively recomposed them due to unproven parameter stability, creating hidden performance costs.
6The Stability System Explained
Compose determines whether to skip recomposition by checking parameter stability - whether types can be proven immutable at compile time. Unannotated data classes (AccountBalance, Transaction, etc.) and interface parameters (like List<PromotionOffer>) force Compose to defensively recompose, as it can't guarantee they won't mutate after being passed to composables.
7The @Immutable Annotation Gap
While data classes automatically implement equals(), they lack Compose's required stability guarantees without @Immutable annotation. This causes Compose to recompose them unnecessarily. The situation worsens with collection parameters, as Compose can't know if a List interface is backed by an immutable implementation at compile time.
8Performance Impact Quantified
The BalanceScreen's allocation profile showed nearly 15,000 allocations in doCompose and 12,805 in recomposeToGroupEnd - orders of magnitude more work than Layout Inspector suggested. These hidden costs accumulate in production apps, affecting battery life and responsiveness without appearing in standard profiling tools.
9Diagnostic Methodology
Effective diagnosis requires: 1. Comparing Layout Inspector counts with Memory Profiler allocations 2. Examining call stacks for compose runtime entries 3. Checking stability warnings in Compose compiler reports 4. Annotating data classes with @Immutable or @Stable 5. Using remember with keys for expensive calculations.
10Key Takeaways for Developers
1. Layout Inspector doesn't show the full recomposition picture 2. Memory allocations reveal hidden work 3. Unannotated data classes force defensive recomposition 4. Interface parameters are inherently unstable 5. Proper annotation and stability awareness can significantly improve Compose performance in production apps.
Original article: ProAndroidDev - Medium — Unseen Recomposition Costs in Jetpack Compose