6 min read

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.

AndroidJetpack ComposePerformanceMemoryLeakCanary
HR

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:

  1. Open Android Studio's Profiler → Memory.
  2. Navigate into the suspect screen and back out. Ten times.
  3. Force GC (the bin icon), then Capture heap dump.
  4. 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.

Keep reading