The Silent Performance Killer: How a Single Lambda Nearly Destroyed My Compose UI
A deep dive into Jetpack Compose recomposition, data classes, and why your “stable” state might be lying to you
The Silent Performance Killer: How a Single Lambda Nearly Destroyed My Compose UI
A deep dive into Jetpack Compose recomposition, data classes, and why your “stable” state might be lying to you

The Problem That Shouldn’t Exist
It started innocently enough. I was building a navigation control system for our Navigation App at GoTo. Nothing fancy, just a camera control state that toggles between Hidden, Recenter, and Route Overview modes. The code looked clean, the logic was sound, and everything seemed fine.
Then I noticed something odd in the Layout Inspector: my CartoNavigationControls composable was recomposing every single second. Not occasionally. Not when state changed. Every. Single. Second.

Figure 1. Excessive Recomposition on the CartoNavigationControls
01-11 09:56:12.671 D NavigationControls: SIDE_EFFECT recomposed
01-11 09:56:13.686 D NavigationControls: SIDE_EFFECT recomposed
01-11 09:56:14.685 D NavigationControls: SIDE_EFFECT recomposed
01-11 09:56:15.686 D NavigationControls: SIDE_EFFECT recomposed
Initially, I thought that it was okay since the navigation system had changes on the navigation UI state based on the position changes. However, having excessive recomposition on the NavigationControls did not seem right.
The Investigation Begins
My first instinct was to check the obvious culprits. I added logging to track all collected state:
val poi by viewModel.poi.collectAsStateWithLifecycle()
val username by viewModel.username.collectAsStateWithLifecycle()
val isPickupFlow by viewModel.isPickupFlow.collectAsStateWithLifecycle()
val isMuted by viewModel.isMuted.collectAsStateWithLifecycle()
val uiState by viewModel.navigationUiState.collectAsStateWithLifecycle()
SideEffect {
Log.d("SIDE_EFFECT",
"poi=${poi?.hashCode()} " +
"username=${username?.hashCode()} " +
"isPickupFlow=$isPickupFlow " +
"isMuted=$isMuted"
)
}
The output was… confusing:
SIDE_EFFECT: poi=-625714860 username=-868613176 isPickupFlow=true isMuted=false
SIDE_EFFECT: poi=-625714860 username=-868613176 isPickupFlow=true isMuted=false
SIDE_EFFECT: poi=-625714860 username=-868613176 isPickupFlow=true isMuted=false
Nothing was changing. The hash codes were identical. The values were stable. Yet my composable was recomposing like it was being paid by the frame.
This made no sense. Compose’s whole genius is that it doesn’t recompose when nothing changes. So what was I missing?
The Deep Dive: Tracking Every Parameter
I needed to go deeper. Every composable parameter needed to be tracked, hence I put a log on the NavigationControls composable.
@Composable
fun CartoNavigationControls(
// dozen of args :)
) {
Log.d("DEBUG", "CartoNavigationControls params: " +
"viewModel=${viewModel.hashCode()} " +
"config=${config.hashCode()} " +
"isMuted=$isMuted " +
"cameraControlState=${cameraControlState.hashCode()} " +
"onBackClicked=${onBackClicked.hashCode()} " +
"onMapClicked=${onMapClicked.hashCode()}"
)
// ...
}
And there it was:
10:05:12 cameraControlState=154745060
10:05:13 cameraControlState=112856671 👈 DIFFERENT!
10:05:14 cameraControlState=198477068 👈 DIFFERENT!
10:05:15 cameraControlState=81644341 👈 DIFFERENT!
Every single second, cameraControlState was getting a new hash code. But why? Let me show you the state definition:
sealed class CameraControlState {
data object Hidden : CameraControlState()
data class ShowRecenter(
val updateCamera: () -> Unit
) : CameraControlState()
data class ShowRouteOverview(
val updateCamera: () -> Unit
) : CameraControlState()
}
See it? That innocent-looking data class with a lambda inside? That’s the killer.
The Root Cause: Data Classes Don’t Play Nice With Lambdas
Here’s what I didn’t fully appreciate: lambdas in Kotlin use referential equality, not structural equality.
Every time the parent composable recomposes, it recreates the lambda:
// Recomposition 1
val state1 = ShowRecenter(updateCamera = { camera.recenter() })
// Recomposition 2
val state2 = ShowRecenter(updateCamera = { camera.recenter() })
// These look the same, do the same thing, but...
println(state1.updateCamera == state2.updateCamera) // false ❌
And because data class auto-generates equals() and hashCode() based on all properties (including the lambda), this happens:
println(state1 == state2) // false ❌
Compose sees them as different objects → triggers recomposition → creates new lambda → repeat forever.
Failed Solutions: Learning What Doesn’t Work
❌ Attempt 1: Custom Equals (But Wrong)
My first attempt was to write a custom equals():
data class ShowRecenter(val updateCamera: () -> Unit) : CameraControlState() {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShowRecenter
return updateCamera == other.updateCamera // ❌ Still fails!
}
}
❌ Attempt 2: Just Remove the data Keyword
I thought: “If data class is the problem, just remove it!”
class ShowRecenter(val updateCamera: () -> Unit) : CameraControlState()
This also didn’t work. Why? Without custom equals(), Compose falls back to referential equality. But we’re still creating new instances on every recomposition, so they’re still different objects.
❌ Attempt 3: Wrapping in Remember/Key
remember { Column { /* composable */ } } // ❌ Doesn't help
key(state) { Column { /* composable */ } } // ❌ Wrong approach, what are you doing?
These don’t address the root cause — the state itself is unstable.
The Ultimate Fix: Ignore the Lambda in Equality
After all this trial and error, here’s what actually worked:
sealed class CameraControlState {
data object Hidden : CameraControlState()
@Stable
class ShowRecenter(val updateCamera: () -> Unit) : CameraControlState() {
override fun equals(other: Any?): Boolean {
return other is ShowRecenter // ✅ Ignore the lambda!
}
override fun hashCode(): Int {
return javaClass.hashCode() // ✅ Consistent hash
}
}
@Stable
class ShowRouteOverview(val updateCamera: () -> Unit) : CameraControlState() {
override fun equals(other: Any?): Boolean {
return other is ShowRouteOverview
}
override fun hashCode(): Int {
return javaClass.hashCode()
}
}
}
Here’s to showcase where the CartoNavigationControls now get skips appropriately.

Figure 2. Stopping the Strom
Why this works:
@Stabletells Compose to trust our stability contract- Custom
equals()only checks the type, completely ignoring the lambda - Custom
hashCode()returns a consistent value based on class type
Now when Compose compares states:
val state1 = ShowRecenter { camera.recenter() }
val state2 = ShowRecenter { camera.recenter() }
println(state1 == state2) // ✅ true!
The recomposition storm stopped immediately. :)
The Semantic Decision
By implementing equals() this way, i am making a deliberate semantic choice:
“Any two
ShowRecenterinstances are equal, regardless of their lambda implementation”
This is correct when:
- ✅ The lambda always represents the same logical action
- ✅ You’re using state to control UI visibility, not behavior
- ✅ The lambda is recreated due to parent recomposition, not actual logic changes
This is dangerous when:
- ❌ Different lambdas have genuinely different behavior (Don’t do this!)
- ❌ You need to distinguish between different instances of the same state type
The Even Better Solution: Separate State from Actions
While the fix above works, there’s an even cleaner approach following Compose best practices:
// ✅ Pure state, no lambdas
sealed class CameraControlState {
data object Hidden : CameraControlState()
data object ShowRecenter : CameraControlState()
data object ShowRouteOverview : CameraControlState()
}
// ✅ Actions passed separately
@Composable
fun CartoNavigationScreen(
cameraControlState: CameraControlState,
onRecenterCamera: () -> Unit,
onShowRouteOverview: () -> Unit,
) {
when (cameraControlState) {
Hidden -> { /* hide controls */ }
ShowRecenter -> Button(onClick = onRecenterCamera) { }
ShowRouteOverview -> Button(onClick = onShowRouteOverview) { }
}
}
Benefits:
- State is truly stable (no lambdas)
- Separation of concerns (state vs. behavior)
- Easier to test
- More explicit about what triggers actions
Key Lessons Learned
- Data classes + lambdas = unstable state in Compose
- Lambdas use referential equality, not structural equality
@Stableis a contract, not magic—you need to implement properequals()- Type-based equality can be valid when the lambda represents the same semantic action
- Separating state from actions is almost always cleaner
The Performance Impact
This wasn’t just an academic exercise.
Before the fix:
- 60+ unnecessary recompositions per minute
- Wasted CPU cycles on every frame
- Potential dropped frames during navigation
- Battery drain from unnecessary work
After the fix:
- Recomposition only when state actually changes
- Smooth 60fps rendering
- Cleaner profiler traces
- Happy drivers (and engineering team) #ihope!
Closing Thoughts
Compose is incredibly powerful, but it’s built on subtle contracts about stability and equality that aren’t always obvious. A single data class with a lambda can silently destroy your performance while your logs insist nothing is wrong.
The next time you see mysterious recompositions:
- Log every parameter hash code
- Look for data classes containing lambdas
- Question your equality implementation
- Consider separating state from actions
And remember: just because your state looks stable doesn’t mean Compose thinks it is.
Raditya Gumay is a Sr. Principal Engineer at GoTo, where he leads the Maps & Navigation platform serving millions of Gojek’s users daily. He’s currently actively contribute to the MapLibre and Stadiamaps/Ferrostar as maintainer and contributor especially for debugging Android performance issues so you don’t have to.
Found this helpful? Have war stories of your own? Drop a comment below.
Related Reading:
메타데이터
- post_id
- 8ffc76357766
- slug
- the-silent-performance-killer-how-a-single-lambda-nearly-destroyed-my-compose-ui-8ffc76357766
- url
- https://medium.com/gojekengineering/the-silent-performance-killer-how-a-single-lambda-nearly-destroyed-my-compose-ui-8ffc76357766
- canonical_url
- https://medium.com/gojekengineering/the-silent-performance-killer-how-a-single-lambda-nearly-destroyed-my-compose-ui-8ffc76357766
- author_url
- https://medium.com/@gumay.raditya
- status
- ok
- fetched_at
- 2026-06-13 09:11:36