Memory Leaks in Compose: What LeakCanary Won't Tell You
LeakCanary watches Activities, Fragments, Views and ViewModels — not remembered objects. The four Compose leak shapes it misses, why the fourth isn't technically a leak at all, and the four-minute heap-dump routine that finds all of them.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — LeakCanary answers one question: "was this watched object collected after it was
destroyed?" Its watch list is Activities, Fragments, Views, and ViewModels. Objects held by remember
aren't on it, so four common Compose leaks pass silently: self-made CoroutineScopes, listeners
registered without a matching onDispose, Context captured in remembered lambdas, and unbounded state
growth — which isn't a leak at all, just memory you never release. Find all four with a heap dump
filtered to your package, sorted by retained size, after repeating the navigation ten times.
What LeakCanary is actually doing
ObjectWatcher is handed specific objects at specific moments — an Activity at onDestroy, a Fragment
at onDestroyView, a ViewModel at onCleared. It holds a weak reference, forces a GC, and reports the
reference chain if the object survived.
That's a precise and useful question. It's also a narrow one. Anything that was never handed to the
watcher can leak indefinitely without producing a single notification, and Compose's unit of state —
remembered objects living in the composition — is not on the list.
So "LeakCanary is silent" and "memory grows 8MB per screen visit" are entirely compatible statements.
Shape 1: a CoroutineScope you made yourself
The most damaging one, and it looks reasonable:
@Composable
fun Dashboard() {
// Nothing cancels this. Ever.
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
LaunchedEffect(Unit) {
scope.launch { pollForever() }
}
}
remember stores the scope; leaving composition drops the reference but never cancels the Job. The
coroutines keep running, keep whatever they captured alive, and a new scope is created every time the
screen opens.
// Cancels automatically when this leaves composition
val scope = rememberCoroutineScope()
If you genuinely need a custom scope — a different dispatcher, a CoroutineExceptionHandler — tie its
lifetime to the composition explicitly:
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
DisposableEffect(Unit) { onDispose { scope.cancel() } }
Better still, move it to the ViewModel and use viewModelScope. A screen that needs work outliving its
composition is describing a ViewModel.
Shape 2: a listener registered without disposal
Any registration with a system service holds a reference to your callback, and through it to the composition and everything captured:
// Leaks the listener, and everything it captured, per composition
LaunchedEffect(Unit) {
sensorManager.registerListener(listener, sensor, SENSOR_DELAY_NORMAL)
}
// Correct
DisposableEffect(sensorManager) {
sensorManager.registerListener(listener, sensor, SENSOR_DELAY_NORMAL)
onDispose { sensorManager.unregisterListener(listener) }
}
The rule: LaunchedEffect for suspending work, DisposableEffect for anything that registers. If
your effect block contains a call starting with register, add, subscribe, observe or attach,
it needs onDispose.
The usual suspects: SensorManager, LocationManager, BroadcastReceiver,
ViewTreeObserver.OnGlobalLayoutListener, ConnectivityManager.NetworkCallback, MediaPlayer, and any
third-party SDK with an addXListener.
Shape 3: Context captured in a remembered value
@Composable
fun ShareButton() {
val context = LocalContext.current
// Remembered across configuration change → holds the destroyed Activity
val onShare = remember { { context.startActivity(shareIntent) } }
Button(onClick = onShare) { Text("Share") }
}
remember without keys survives recomposition, and the lambda captures the Activity Context. Give it
the key, and the leak disappears:
val onShare = remember(context) { { context.startActivity(shareIntent) } }
Or don't remember it at all — a lambda literal passed to onClick is fine, and the Compose compiler
already handles that case. The variant worth being strict about is remember { } holding anything
Activity-scoped: a Context, a View, a Window, an Activity reference for permissions.
Shape 4: the leak that isn't a leak
This one causes more real OOMs than the other three, and it will never appear in any leak detector, because nothing is retained incorrectly:
class FeedViewModel : ViewModel() {
private val _items = MutableStateFlow<List<Item>>(emptyList())
fun loadMore(page: Int) {
_items.value = _items.value + api.load(page) // only ever grows
}
}
Scroll far enough and you're holding thousands of items, each with an image URL, some with bitmaps behind them. The ViewModel is alive by design; every reference is correct; memory climbs until it doesn't.
Also in this category: a SnapshotStateList used as an event log, a cache with no eviction policy, and
derivedStateOf retaining a large intermediate. The question to ask about any growing collection is
"what bounds this?" — and "the user gets bored" is not a bound.
The four-minute routine that finds all of them
Don't reason about it. Measure it:
- Open Android Studio's Profiler → Memory.
- Navigate into the suspect screen and back out. Ten times.
- Force GC (the bin icon), then Capture heap dump.
- Filter to your package, sort by retained size, and read the instance counts.
Ten navigations into one screen should leave one instance of that screen's objects, not ten. A count of exactly ten is unambiguous, and it points straight at the class. Then click the instance and read the reference chain to the GC root — that's your answer.
Two supporting tools:
// Watch your own objects — LeakCanary will report them like an Activity
AppWatcher.objectWatcher.expectWeaklyReachable(myObject, "Dashboard scope disposed")
expectWeaklyReachable is how you extend LeakCanary to Compose-shaped objects; it's the fix for the
gap this whole post describes. And on the growth side, Debug.dumpHprofData() behind a debug menu lets
you capture from a real device when the leak only reproduces in the field.
The rule
"No leak detected" means "no watched object leaked." In Compose, most of what actually grows was never on the watch list — so the routine that finds it is a heap dump with a repeat count, not a notification you wait for.