The Recompositions That Weren’t There!

How memory allocation data revealed what the Layout Inspector was hiding about Compose recompositions.

I recently went down a rabbit hole trying to understand why a production Compose screen felt heavier than it should. What started as a simple profiling session turned into something bigger. The Layout Inspector and the Memory Profiler were telling me two completely different stories about what was happening. This is what I found, what I fixed, and what I wish I’d known earlier.

If you’ve worked with Jetpack Compose long enough, you’ve probably had that moment where you open the Layout Inspector, check the recomposition counts, see nothing alarming and move on. That’s exactly what I used to do, until I started asking a different question: what if the recomposition count is accurate but incomplete?

I was investigating a BalanceScreennothing not normal just a balance display, a transaction list, a few promotional cards. The kind of screen that loads data once and settles. The Layout Inspector agreed: a handful of recompositions when state arrived, then quiet. Unremarkable.

But the allocation table told a completely different story. doCompose-aFTINEg: 14,873 allocations. recomposeToGroupEnd: 12,805. ComposableLambdaImpl.invoke: thousands of entries buried deep in the call stack. For a screen that according to every other tool I trusted was sitting still.

Something was recomposing that the Layout Inspector couldn’t see. Or worse, it could see it but wasn’t telling me what I needed to know.

Why I was looking at allocations in the first place

I’d been trying to understand the actual cost of recompositions on this screen. Not just how many times things recomposed, but what each recomposition was doing in terms of real work. Allocation counts felt like a more honest signal than the Layout Inspector’s recomposition counter because every recomposition produces concrete artifacts: lambda instances get created, RecomposeScopeImpl objects get tracked, ComposableLambdaImpl invocations happen. These are real objects. They show up in the allocation table whether the Layout Inspector acknowledges them or not.

The setup was straightforward: Android Studio’s Track Memory Consumption (Java/Kotlin Allocations) recording, filtered by App → Callstack. I was scanning for compose runtime entries like group(), recomposeScopeImpl, synthetic lambdas like BalanceScreen$ExternalSyntheticLambda. Anything that would tell me which composables were being re-invoked and how often.

I wasn’t expecting to find a discrepancy. I was expecting to confirm what the Layout Inspector already showed, just with more detail.

When the numbers stopped agreeing

I opened Android Studio’s Memory Profiler, started a recording and navigated to the BalanceScreen. When I expanded the call stacks and sorted by total allocation count, the picture changed fast.

Profiler view showing doCompose-aFTINEg at 14,873 allocations, the full internal chain (recomposeToGroupEnd → compose → invoke), ComposableLambdaImpl, and individual composable entries with dramatically high numbers.

The allocation profile before any changes. doCompose at nearly 15,000 allocations. recomposeToGroupEnd at 12,805. The Compose runtime was doing far more work than the Layout Inspector ever hinted at.

When you look closely at the profiler, Compose’s internal machinery reveals a lot of work happening under the surface. In the call stack, runtime methods like recomposeToGroupEnd, skipToGroupEnd, and ComposableLambdaImpl.invoke were firing thousands of times under composables that the Layout Inspector claimed were completely calm.

Here is what was actually going on:

The Layout Inspector only increments its counter when an entire composable scope finishes recomposing. But before Compose decides whether to skip a composable (via skipToGroupEnd) or execute its scope (recomposeToGroupEnd), its engine still has to run internal checks under the hood. It re-evaluates remember blocks to see if keys changed, re-instantiates captured lambdas (ComposableLambdaImpl.invoke), and diffs parameters to check for stability.

All of that checking and comparing creates real memory allocations. The Layout Inspector treats that background work as “nothing happened” because the UI didn’t redraw. But the Memory Profiler catches every single object created along the way.

Two big problems were hiding under those numbers:

1. Invisible re-evaluations: Code paths inside composables were re-running and allocating temporary objects without triggering a formal “recomposition” count in the inspector.

2. Misrepresented stability checks: Composables that did recompose looked completely normal in the Layout Inspector, hiding the fact that Compose was re-running them defensively because it couldn’t guarantee their parameter types were stable.

I started digging into the code to understand what was actually triggering all this work.

The patterns I found

Five patterns. Each one was hiding in plain sight.

Unstable data classes

This was the biggest one. Compose decides whether to skip recomposing a composable by checking if its parameters are stable, meaning Compose can prove at compile time that if two instances are equal they’ll always be equal, and that changes will be notified through the snapshot system.

Data classes like AccountBalance, Transaction, PromotionsData, TransactionGroupData, none of them were annotated with @Immutable. Intuitively data classes feel stable. They have equals() generated for free. But Compose’s stability checker doesn’t just care about equality, it cares about guarantees. Without @Immutable, Compose can’t be sure the class won’t be mutated after being passed to a composable, so it defensively recomposes every time.

It got worse PromotionsData held a List<PromotionOffer>, and kotlin.collections.List is an interface. The underlying implementation could be a MutableList. Compose can’t know at compile time so it treats any composable receiving this parameter as unstable and recomposes it every single time, even when the list contents are identical.

The fix:

// Before
data class PromotionsData(val offers: List<PromotionOffer> = emptyList())

// After
@Immutable
data class PromotionsData(val offers: ImmutableList<PromotionOffer> = persistentListOf())

@Immutable on the data classes. ImmutableList from kotlinx.collections.immutable replacing List. persistentListOf() in the ViewModels where the state was being constructed.

The Layout Inspector couldn’t show me this was happening because it counted these recompositions the same way it counted legitimate ones. A recomposition triggered by actual data change and one triggered by Compose’s inability to prove stability look identical in the counter.

Objects recreated every frame

Inside a card composable that showed a formatted balance, a buildAnnotatedString { … } block was executing on every recomposition. Every time the parent recomposed, for any reason, this block created a new AnnotatedString, new SpanStyle instances, resolved colors from the theme again, pulled font weights again. All unnecessary because the inputs hadn’t changed.

Same story in a pager dot indicator: AppTheme.colors.labelPrimary.copy(alpha = 0.3f) was being called inside a repeat loop. Every recomposition, every iteration, a new Color object allocated. A gradient Brush.verticalGradient(…) in a background composable, recreated every frame.

// Before — new AnnotatedString on every recomposition
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = AppTheme.colors.labelPrimary, ...)) {
append(amountText)
}
withStyle(SpanStyle(color = AppTheme.colors.labelDisabled, ...)) {
append(" / $thresholdText")
}
},
)

// After — cached until inputs actually change
val primaryColor = AppTheme.colors.labelPrimary
val disabledColor = AppTheme.colors.labelDisabled
val annotatedText = remember(amountText, thresholdText, primaryColor, disabledColor) {
buildAnnotatedString {
withStyle(SpanStyle(color = primaryColor, ...)) {
append(amountText)
}
withStyle(SpanStyle(color = disabledColor, ...)) {
append(" / $thresholdText")
}
}
}
Text(text = annotatedText)kotlin

This was invisible to the Layout Inspector for a simple reason: these aren’t separate recompositions. They’re allocations within a composable that’s already recomposing. The Inspector counts recomposition of composable scopes, not individual object allocations inside them. So the allocation profiler saw thousands of unnecessary objects while the Inspector saw nothing unusual.

Unstable lambdas cascading downward

An info bottom sheet composable had an onDismiss callback that was being recreated on every recomposition because it captured mutable values directly. Every time the parent recomposed a new lambda instance was created, and every child composable that received it as a parameter saw it as a “changed” parameter and recomposed.

// Before — new lambda instance every recomposition
val onDismiss = {
when (sheetType) { // captures mutable value directly
is SheetType.Success -> onAction(HideSuccess)
is SheetType.Info -> onAction(HideInfo)
// ...
}
}

// After — stable lambda reference across recompositions
val latestAction by rememberUpdatedState(onAction)
val latestSheetType by rememberUpdatedState(sheetType)
val onDismiss = remember {
{
when (latestSheetType) { // reads from stable State reference
is SheetType.Success -> latestAction(HideSuccess)
is SheetType.Info -> latestAction(HideInfo)
// ...
}
}
}

A single unstable lambda doesn’t cause one unnecessary recomposition. It causes recomposition of every composable that receives it, and potentially their children too. The Layout Inspector showed these as legitimate recompositions because technically they were. The parameter did change. It just changed unnecessarily.

Missing keys in lazy lists

The transactions list used stickyHeader { … } and itemsIndexed(…) without keys. Without keys Compose can’t associate items with their identity across recompositions, so when the list updates or when the containing composable recomposes for any reason, Compose may recompose items that didn’t change or recompose them in the wrong order and then correct itself.

The fix was straightforward: key = “header_${section.date}” on sticky headers, key = { _, transaction -> transaction.uniqueId } on itemsIndexed.

Without keys Compose doesn’t necessarily recompose more items, but it may recompose the wrong items, doing unnecessary diffing work that shows up as allocation churn in the profiler while looking like normal lazy list behavior in the Inspector.

Unnecessary wrapper composables

A ScreenTopBar composable existed solely to forward parameters to the design system’s TopAppBar. It took an onInfoClick lambda and an onHeightMeasured callback, resolved LocalDensity.current and passed everything through. Every time the parent screen recomposed this wrapper created its own recomposition scope. One more scope to track, one more scope that could recompose, for zero added behavior.

The fix was deleting it and inlining the TopAppBar call directly into the parent screen.

It appeared as a separate composable in the Layout Inspector, which made the recomposition count look like “normal composable behavior.” It was pure overhead wearing a disguise.

The result

Profiler view showing allocations now in the ~2,000–2,800 range, with composable entries at dramatically reduced counts.

The same screen after the changes. Allocations dropped from ~14,800 to the low thousands. The Compose runtime is doing a fraction of the work it was doing before.

What this changed for me

The Layout Inspector tells you that recompositions happen. It doesn’t tell you why. It can’t distinguish “recomposed because data changed” from “recomposed because Compose couldn’t prove stability.” For that you need the allocation profiler, or at minimum the Compose compiler stability reports.

@Immutable and ImmutableList aren’t optimizations. They’re correctness annotations. Without them Compose assumes the worst and recomposes defensively. You’re not making things faster by adding them. You’re telling the compiler what’s actually true about your data.

remember isn’t just for state. It’s for stabilizing object identity. A buildAnnotatedString call that produces the same result every time still creates a new object every time unless you remember it. And that new object can trigger downstream recompositions if it’s passed as a parameter.

If your composable allocates 500 objects per recomposition and recomposes 30 times, the Layout Inspector says “30 recompositions.” The profiler says “15,000 allocations.” Both are telling the truth. Only one tells you enough.

Final thought

Debugging tools don’t just help you see problems. They shape what you think a problem looks like. For months my mental model of “healthy recomposition” was whatever the Layout Inspector showed me. If the counter looked fine the screen was fine. I never questioned the frame because I trusted what was inside it.

Now the first thing I do when reviewing a Compose screen isn’t opening the Layout Inspector. It’s opening the Memory Profiler. I check the allocation table, scan for synthetic lambdas that shouldn’t be there, look for ComposableLambdaImpl counts that feel too high for what the screen is doing. It takes five minutes and it catches things the Inspector never will.

If you have a Compose screen in production that you think is performing fine, one where the Layout Inspector shows nothing alarming, try profiling its allocations. Sort by total count. Expand the call stacks. You might find recompositions that were never supposed to be there.

If this article saved you some debugging time, you can buy me a coffee. ☕


The Recompositions That Weren’t There! was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.