If someone tells you “Compose is slow”, don’t immediately start micro-optimizing modifiers.
In many real apps, the jank is caused by memory pressure:
- a leak keeps objects alive → heap grows
- the app triggers more frequent / longer GC pauses
- GC pauses show up as “random” frame drops → it feels like UI performance
This note is a practical checklist to prove the leak and fix the most common Compose-related causes.
If your problem is constant recomposition / skipping blockers (especially in lists), read this sister note: Compose Performance: Skipping & Stability (User List Edition) →
/android/compose-skipping-stability-user-list
1) What a Compose-related leak looks like
Symptoms you’ll typically see:
- Scrolling is fine… then becomes janky after a few navigations
- Navigating back doesn’t free the screen’s memory
- The app “gets slower” over time
In logs/traces you might notice:
- increasing heap usage
- frequent GC events (sometimes coinciding with dropped frames)
2) Detecting leaks with LeakCanary (do this first)
Don’t guess. Use LeakCanary.
// app/build.gradle
debugImplementation "com.squareup.leakcanary:leakcanary-android:2.14"How to reproduce:
- Run a debug build
- Open the screen, interact a bit
- Navigate away (back)
- Repeat 2–3 times
If LeakCanary reports that your Activity, Fragment, View, or a Composition is retained, you have proof.
3) The classic leak: registering listeners without unregistering (Context capture)
This happens when you remember an object that registers something (listener, callback, broadcast receiver, sensor, location, etc.)
@Composable
fun SensorScreen() {
val context = LocalContext.current
// ❌ BAD:
// - remember() keeps this instance for the lifetime of this composition
// - if SensorHelper registers listeners and you never unregister
// your Activity can leak after you navigate away
val helper = remember { SensorHelper(context) }
Text("Value: ${helper.latestValue}")
}Fix: manage the lifecycle with DisposableEffect
If something needs cleanup, use DisposableEffect.
@Composable
fun SensorScreen() {
val context = LocalContext.current
// ✅ GOOD:
// - setup happens when entering the composition
// - cleanup happens automatically when leaving the composition
DisposableEffect(context) {
val helper = SensorHelper(context)
helper.startListening()
onDispose {
helper.stopListening() // crucial: prevents Activity leaks
}
}
Text("Listening…")
}Beginner rule of thumb:
remember { ... }does not mean “Compose will clean it up for me”.DisposableEffectis your “onEnter/onExit” for resources.
4) Another common leak: collecting flows without tying them to lifecycle
Example of a risky pattern:
- you start a collection in
LaunchedEffect - but you accidentally create multiple collectors or keep references alive longer than expected
Safer options:
- Use
collectAsStateWithLifecycle()(from lifecycle-runtime-compose) - Or ensure your
LaunchedEffectkey is correct (not alwaysUnit)
(We can write a dedicated note on this next — it’s a big source of subtle bugs.)
5) Verification checklist (make it measurable)
After your fix:
- Repeat the reproduction steps
- Confirm LeakCanary no longer reports the retained instance
- Watch memory in Android Studio profiler: heap should stabilize
- Scroll/navigate: jank should reduce because GC pressure is lower
Key Takeaway
Many “Compose performance” problems are really memory problems. Use LeakCanary to prove a leak, fix missing cleanups with DisposableEffect, then verify: fewer GC pauses → smoother frames.