Fix Compose Recomposition Issues: 2025 Performance Guide

Part 0: TL;DR - Quick Wins & Core Principles (For Skimmers & AI Summaries)
Critical takeaways to fix Compose recomposition issues immediately:
• Defer state reads - Pass lambdas () -> T
instead of direct values T
to modifiers like Modifier.offset { }
• Use stable parameters - Primitives, strings, and @Immutable
/@Stable
annotated classes prevent unnecessary recomposition
• Add keys to LazyColumn/LazyRow - key = { item.id }
blocks are essential for list performance
• Apply remember
wisely - Only for expensive calculations, not all state; use remember(key)
for conditional memoization
• Leverage derivedStateOf
- For computed state that changes less frequently than its inputs
• Write state only in event handlers - Never write state during composition phase
• Use Android Studio's recomposition highlighting - Visual debugging shows exactly what's recomposing
Official References:
Part 1: The Recomposition Revolution: Understanding the "Why" and "What" for 2025
1.1. Jetpack Compose in 2025: A Quick Refresher
Jetpack Compose has matured into the definitive declarative UI toolkit for Android. Unlike imperative UI frameworks where you manually update views, Compose automatically recomposes (rebuilds) UI when state changes. This declarative approach eliminates boilerplate and reduces bugs, but introduces a new performance consideration: optimizing recomposition.
1.2. Recomposition Demystified
What is recomposition? Think of recomposition like a smart painter who only repaints the parts of a mural that have changed. When state updates, Compose doesn't rebuild your entire UI—it intelligently determines which composable functions need to re-execute.
The Three Phases of Compose:
- Composition - Compose decides what the UI should look like
- Layout - Measures and positions elements
- Drawing - Renders pixels to screen
Recomposition occurs in the Composition phase. Smart recomposition is Compose's goal: skip as much work as possible by only recomposing functions whose inputs have actually changed.
Why unoptimized recomposition kills performance: Unnecessary recomposition is the #1 cause of jank, battery drain, and ANRs in Compose apps. Each recomposition consumes CPU cycles, and frequent recomposition creates the stuttering animations users notice immediately.
1.3. The "Cost" of a Recomposition
Under the hood, recomposition involves:
- Re-executing composable function code
- Comparing old vs new state using structural equality
- Potentially triggering layout and drawing phases
- Memory allocation for new composition state
1.4. Stability in Compose: The Key to Skippability
Stability determines whether Compose can skip recomposition. A parameter is stable if:
- Its value doesn't change between recompositions, OR
- Compose can detect when it changes through equality comparison
Stable types: Primitives (Int
, String
, Boolean
), functions, and classes marked @Immutable
or @Stable
Unstable types: Mutable collections, complex objects without stability annotations
The Compose compiler analyzes your code and marks functions as skippable only when all parameters are stable. Unstable parameters force recomposition even when values haven't actually changed.
Part 2: Diagnosing the Disease: Your 2025 Toolkit for Finding Recomposition Hotspots
2.1. Android Studio Layout Inspector
Step-by-step debugging:
- Run your app in debug mode
- Open Tools → Layout Inspector
- Select your device and process
- Enable "Live Updates"
- Look for the "Recomposition Count" column
- High numbers indicate recomposition hotspots
The Layout Inspector shows both recomposition counts and skip counts, giving you immediate visibility into which composables are working efficiently.
2.2. Enabling Recomposition Highlighting
Visual debugging makes recomposition patterns obvious:
// Add to your Application class or main activity
ComposeView.setShowRecompositionHighlighting(true)
Flashing borders around composables indicate recomposition. Frequent flashing reveals performance problems.
2.3. Decoding Compose Compiler Metrics
Generate the report:
android {
compileOptions {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
}
}
The compiler generates detailed reports showing:
- Which composables are skippable vs non-skippable
- Unstable parameter analysis
- Stability inference results
Key insights: Look for "unstable" parameters in your most frequently called composables.
2.4. Android Studio Profiler (CPU Profiler)
Focus on "Recompose" trace sections to identify expensive composable functions. Functions appearing frequently in CPU samples during UI interactions are prime optimization candidates.
2.5. Baseline Profiles & Macrobenchmarks for 2025
Baseline Profiles provide startup performance improvements, while Macrobenchmarks prevent recomposition regressions in CI/CD:
@Test
fun recompositionBenchmark() {
benchmarkRule.measureRepeated {
// Your UI interaction that should minimize recomposition
}
}
Part 3: The Cure: 15+ Advanced Techniques & Best Practices for Flawless Recomposition
Essential State Read/Write Rules
3.1. Rule #1: Defer Reads as Late as Possible
Problem: Reading state early forces recomposition of entire composables.
Solution: Pass lambdas instead of values to defer state reads.
// ❌ Bad: Reads offset immediately
@Composable
fun BadExample(offset: Dp) {
Box(
modifier = Modifier.offset(x = offset)
)
}
// ✅ Good: Defers read until layout phase
@Composable
fun GoodExample(offsetProvider: () -> Dp) {
Box(
modifier = Modifier.offset { IntRect(offsetProvider().roundToPx(), 0) }
)
}
3.2. Rule #2: Write State Only in Event Handlers
Never write state during composition—this creates infinite recomposition loops.
// ❌ Bad: Writing state during composition
@Composable
fun BadCounter() {
var count by remember { mutableStateOf(0) }
count++ // This causes infinite recomposition!
Text("Count: $count")
}
// ✅ Good: Write state in event handlers
@Composable
fun GoodCounter() {
var count by remember { mutableStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increment")
}
}
}
Mastering State & Stability
3.3. remember Wisely
Use remember
for expensive calculations, not just any state:
@Composable
fun ExpensiveList(items: List<Item>) {
// ✅ Good: Remember expensive computation
val processedItems = remember(items) {
items.map { it.expensiveTransformation() }
}
LazyColumn {
items(processedItems) { item ->
ItemCard(item)
}
}
}
3.4. remember(key): Conditional Memoization
@Composable
fun ConditionalContent(userId: String, showDetails: Boolean) {
// Only recompute when userId changes, not showDetails
val userProfile = remember(userId) {
fetchUserProfile(userId)
}
if (showDetails) {
DetailedProfile(userProfile)
} else {
SimpleProfile(userProfile)
}
}
3.5. derivedStateOf: For Frequently Changing State
@Composable
fun ScrollToTopButton(listState: LazyListState) {
// ✅ Derived state changes less frequently than scroll position
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
AnimatedVisibility(visible = showButton) {
FloatingActionButton(onClick = { /* scroll to top */ })
}
}
3.6. Ensuring Input Stability
Stable types work automatically:
@Composable
fun StableInputs(
text: String, // ✅ Stable
count: Int, // ✅ Stable
onClick: () -> Unit // ✅ Stable function type
) { /* ... */ }
Make custom classes stable:
@Immutable
data class UserProfile(
val name: String,
val email: String,
val avatarUrl: String
)
@Stable
class AnimationController {
// Mutable internal state, but stable public interface
private var _progress by mutableStateOf(0f)
val progress: Float get() = _progress
fun animateTo(target: Float) {
_progress = target
}
}
3.7. Lambdas and Stability
Lambdas capturing unstable values become unstable:
@Composable
fun UnstableLambda(items: MutableList<String>) {
// ❌ Lambda captures unstable MutableList
LazyColumn {
items(items) { item ->
Text(
text = item,
modifier = Modifier.clickable {
items.remove(item) // Captures unstable items
}
)
}
}
}
// ✅ Solution: Use stable callback
@Composable
fun StableLambda(
items: List<String>,
onRemoveItem: (String) -> Unit // Stable callback
) {
LazyColumn {
items(items) { item ->
Text(
text = item,
modifier = Modifier.clickable { onRemoveItem(item) }
)
}
}
}
Optimizing Lists & Complex UIs
3.8. LazyLayouts: Keys are Critical
@Composable
fun OptimizedList(items: List<Item>) {
LazyColumn {
items(
items = items,
key = { item -> item.id }, // ✅ Essential for performance
contentType = { item -> item.type } // ✅ Helps with recycling
) { item ->
when (item.type) {
ItemType.Header -> HeaderItem(item)
ItemType.Content -> ContentItem(item)
}
}
}
}
3.9. Custom Layouts and Recomposition
When creating custom layouts, be mindful of measurement policy stability:
@Composable
fun CustomLayout(
spacing: Dp,
content: @Composable () -> Unit
) {
// ✅ Remember the measurement policy to avoid recomposition
val measurePolicy = remember(spacing) {
MeasurePolicy { measurables, constraints ->
// Custom layout logic
}
}
Layout(
content = content,
measurePolicy = measurePolicy
)
}
Advanced Strategies & Refactoring
3.10. Higher-Order Composables
// ✅ Pass stateful lambdas for better recomposition control
@Composable
fun HigherOrderComposable(
contentProvider: @Composable () -> Unit
) {
Card {
contentProvider()
}
}
3.11. Slot APIs Impact
Slot APIs with @Composable () -> Unit
parameters can cause recomposition of the entire slot when any internal state changes. Consider breaking down complex slots:
@Composable
fun ComplexCard(
header: @Composable () -> Unit,
content: @Composable () -> Unit,
actions: @Composable () -> Unit
) {
// Each slot recomposes independently
Card {
Column {
header()
content()
actions()
}
}
}
3.12. Isolating Recomposition Scopes
Create smaller, independent composables to limit recomposition scope:
// ❌ Bad: Counter state affects entire screen
@Composable
fun BadScreen() {
var counter by remember { mutableStateOf(0) }
Column {
Header() // Recomposes unnecessarily
CounterDisplay(counter)
Footer() // Recomposes unnecessarily
Button(onClick = { counter++ }) { Text("Increment") }
}
}
// ✅ Good: Isolate counter logic
@Composable
fun GoodScreen() {
Column {
Header() // Never recomposes
CounterSection() // Only this section recomposes
Footer() // Never recomposes
}
}
@Composable
private fun CounterSection() {
var counter by remember { mutableStateOf(0) }
Column {
CounterDisplay(counter)
Button(onClick = { counter++ }) { Text("Increment") }
}
}
3.13. CompositionLocal Performance
CompositionLocal changes trigger recomposition of all consumers. Use sparingly and consider alternatives:
// ✅ Provide stable values through CompositionLocal
val LocalTheme = compositionLocalOf<Theme> { error("No theme provided") }
@Composable
fun ThemedContent() {
// This recomposes when LocalTheme changes
val theme = LocalTheme.current
// Consider deriving only what you need
val primaryColor = remember(theme) { theme.primaryColor }
}
3.14. Side-Effects and Recomposition
Manage side-effects properly to prevent unwanted recomposition:
@Composable
fun EffectsExample(userId: String) {
var userData by remember { mutableStateOf<UserData?>(null) }
// ✅ LaunchedEffect with proper key
LaunchedEffect(userId) {
userData = fetchUserData(userId)
}
// ✅ Use rememberCoroutineScope for event-driven side effects
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
refreshUserData(userId)
}
}
) {
Text("Refresh")
}
}
3.15. Composition Tracing (2025 Focus)
For ultra-fine-grained debugging, use composition tracing:
@Composable
fun TracedComposable() {
Trace.beginSection("MyComposable")
try {
// Your composable content
ExpensiveComposableContent()
} finally {
Trace.endSection()
}
}
3.16. ViewModel State and collectAsStateWithLifecycle
@Composable
fun ViewModelIntegration(viewModel: MyViewModel) {
// ✅ Lifecycle-aware state collection
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// ✅ Break down complex state objects
val isLoading = uiState.isLoading
val data = uiState.data
val error = uiState.error
when {
isLoading -> LoadingScreen()
error != null -> ErrorScreen(error)
else -> ContentScreen(data)
}
}
Part 4: Real-World Case Studies & Anti-Patterns (2025 Edition)
4.1. Case Study 1: Optimizing a Laggy Social Feed
Before: A social media feed was dropping frames due to excessive recomposition.
// ❌ Problematic implementation
@Composable
fun SocialFeed(posts: List<Post>) {
var likedPosts by remember { mutableStateOf(setOf<String>()) }
LazyColumn {
items(posts) { post -> // Missing key!
PostCard(
post = post,
isLiked = post.id in likedPosts, // Causes full list recomposition
onLikeClick = {
likedPosts = if (post.id in likedPosts) {
likedPosts - post.id
} else {
likedPosts + post.id
}
}
)
}
}
}
After: Optimized with stable keys and isolated state management.
// ✅ Optimized implementation
@Composable
fun SocialFeed(
posts: List<Post>,
likedPosts: Set<String>,
onLikeToggle: (String) -> Unit
) {
LazyColumn {
items(
items = posts,
key = { it.id }, // ✅ Stable keys
contentType = { it.type }
) { post ->
PostCard(
post = post,
isLiked = post.id in likedPosts,
onLikeClick = { onLikeToggle(post.id) }
)
}
}
}
// ✅ Stable PostCard implementation
@Composable
fun PostCard(
post: Post,
isLiked: Boolean,
onLikeClick: () -> Unit
) {
// Implementation with stable parameters
}
Results: 60% reduction in recomposition count, smooth 60fps scrolling.
4.2. Case Study 2: Complex Form Optimization
A complex form with interdependent fields was causing performance issues:
// ✅ Optimized form with isolated field state
@Composable
fun OptimizedForm() {
var formState by remember { mutableStateOf(FormState()) }
Column {
// Each field manages its own recomposition scope
FormField(
value = formState.name,
onValueChange = { formState = formState.copy(name = it) },
label = "Name"
)
FormField(
value = formState.email,
onValueChange = { formState = formState.copy(email = it) },
label = "Email"
)
// Validation only recomposes when relevant fields change
ValidationErrors(
errors = remember(formState.name, formState.email) {
validateForm(formState)
}
)
}
}
4.3. Top 5 Recomposition Anti-Patterns in 2025
- Unstable collections in LazyList items - Always use
key
parameters - Recreating expensive objects - Use
remember
for computations - State writes during composition - Only write in event handlers
- Missing stability annotations - Mark data classes as
@Immutable
- Overusing CompositionLocal - Pass parameters explicitly when possible
Part 5: Future-Proofing Your Compose UI: Maintaining Performance (2025 & Beyond)
5.1. CI/CD Performance Checks
Integrate Macrobenchmarks into your CI pipeline:
// Automated recomposition testing
@RunWith(AndroidJUnit4::class)
class RecompositionRegressionTest {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollingBenchmark() {
benchmarkRule.measureRepeated(
packageName = "com.yourapp",
metrics = listOf(FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.WARM
) {
// Test scrolling performance
device.findObject(By.desc("list")).scroll(Direction.DOWN, 0.8f)
}
}
}
5.2. Team Best Practices & Code Reviews
Code review checklist:
- Are LazyList items using stable keys?
- Are expensive computations wrapped in
remember
? - Do custom classes have appropriate stability annotations?
- Are state writes confined to event handlers?
- Is
derivedStateOf
used for computed state?
5.3. Staying Updated
Follow these resources for the latest Jetpack Compose performance insights:
5.4. The Evolving Compose Compiler
The Compose compiler continues to improve with better stability inference and optimization capabilities. Future versions will provide even more automatic optimizations, but understanding these fundamentals ensures you're prepared for any changes.
Part 6: Conclusion & Your Next Steps
Mastering Jetpack Compose recomposition is essential for building performant Android apps in 2025. The key principles are:
- Defer state reads to minimize recomposition scope
- Ensure parameter stability through proper annotations and types
- Use remember wisely for expensive calculations
- Always provide keys for LazyList items
- Isolate recomposition scopes with smaller composables
Start applying these techniques today by auditing your most performance-critical screens. Use Android Studio's recomposition highlighting to identify hotspots, then systematically apply these optimization patterns.
The investment in understanding recomposition pays dividends in user experience—smooth, responsive UIs that feel native and polished.
Download our Recomposition Optimization Checklist - A comprehensive cheat sheet covering all techniques in this guide, perfect for code reviews and daily development.
What recomposition challenges are you facing in your Compose apps? Share your experiences and questions in the comments below—let's build better Android UIs together!
Ready to dive deeper into Jetpack Compose Performance? Check out our related guides on Complete Jetpack Compose Side Effects Tutorial with Code Examples and Jetpack Navigation 3: Complete Android Developer Guide 2025.