Jetpack Compose Memory Leaks: A Reference-Graph Deep Dive


Jetpack Compose doesn’t “leak by default.” Most Compose leaks are plain old Kotlin reference leaks where something long-lived (a ViewModel, singleton, registry, static object, app scope coroutine) ends up holding a reference to something UI-scoped (an Activity Context, a composable lambda, a CoroutineScope, a remembered object).

If you internalize one idea, make it this:

Leaks happen when composition-scoped references escape into longer-lived holders.

The reference chain behind most Compose leaks

0) The mental model you debug with

  • Composition = runtime tree of nodes backing your UI.
  • remember = stores an object as long as that composable instance stays in the composition.
  • Leaving composition = screen removed / branch removed / ComposeView disposed → Compose runs disposals and cancels effect coroutines.
  • Leak = something outside the composition still references something inside it → GC can’t collect.

1) Coroutine scope myths: what leaks vs what cancels correctly

Not a leak (usually): LaunchedEffect loop

This cancels when the composable leaves composition.

@Composable
fun PollWhileVisibleEffect() {
    LaunchedEffect(Unit) {
        while (true) {
            delay(1_000)
            // do polling work
        }
    }
}

Not a leak (usually): rememberCoroutineScope()

The scope is cancelled when the composable leaves composition.

@Composable
fun ShortLivedWorkButton() {
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch {
            delay(300)
            // short-lived work
        }
    }) {
        Text("Run work")
    }
}

Real leak: GlobalScope / app-wide scope that outlives UI

This can keep references alive far past the screen’s lifecycle.

@Composable
fun LeakyGlobalScopeExample() {
    val context = LocalContext.current

    Button(onClick = {
        // ❌ GlobalScope outlives the UI; captures 'context' (often Activity)
        GlobalScope.launch(Dispatchers.Main) {
            while (true) {
                delay(1_000)
                Toast.makeText(context, "Still running", Toast.LENGTH_SHORT).show()
            }
        }
    }) {
        Text("Start global job")
    }
}

Fixed: tie work to composition OR ViewModel scope intentionally

If the work is UI-only, keep it in UI (LaunchedEffect). If it’s app logic, run it in viewModelScope (and don’t capture UI stuff).

class PollingViewModel : ViewModel() {
    private var pollingJob: Job? = null

    fun startPolling() {
        if (pollingJob != null) return
        pollingJob = viewModelScope.launch {
            while (isActive) {
                delay(1_000)
                // business polling work (no Context!)
            }
        }
    }

    fun stopPolling() {
        pollingJob?.cancel()
        pollingJob = null
    }
}

@Composable
fun ViewModelScopedPollingScreen(viewModel: PollingViewModel) {
    Column {
        Button(onClick = viewModel::startPolling) { Text("Start polling") }
        Button(onClick = viewModel::stopPolling) { Text("Stop polling") }
    }
}

2) Leak Pattern: Singleton/static holder captures composition

Leaky code

object LeakyAppSingleton {
    // ❌ Never store composable lambdas / UI callbacks globally
    var lastScreenContent: (@Composable () -> Unit)? = null
}

@Composable
fun LeakySingletonProviderScreen() {
    val content: @Composable () -> Unit = {
        Text("This can capture composition state")
    }

    LeakyAppSingleton.lastScreenContent = content // ❌

    content()
}

Fixed: store data, not UI

If you need global coordination, use shared state (Flow) or interfaces with explicit unregister and no UI capture.

3) Leak Pattern: remember {} lambda captures + callback registered “forever”

Leaky code

class MyViewModelWithCallbackRegistry : ViewModel() {
    private val callbacks = mutableSetOf<(String) -> Unit>()

    fun registerOnMessageCallback(callback: (String) -> Unit) {
        callbacks += callback
    }

    fun unregisterOnMessageCallback(callback: (String) -> Unit) {
        callbacks -= callback
    }

    fun emitMessage(message: String) {
        callbacks.forEach { it(message) }
    }
}

@Composable
fun LeakyCallbackRegistrationScreen(
    viewModel: MyViewModelWithCallbackRegistry
) {
    val context = LocalContext.current

    // Leaks if this callback is stored in a longer-lived owner (ViewModel) and never unregistered.
    val onMessageCallback: (String) -> Unit = remember {
        { msg ->
            Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
        }
    }

    LaunchedEffect(Unit) {
        viewModel.registerOnMessageCallback(onMessageCallback) // ❌ no unregister
    }

    Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) {
        Text("Emit message")
    }
}

Why it leaks (the reference chain)

ViewModel → callbacks set → lambda → captured context (Activity) → entire UI graph

Fixed code (unregister + avoid stale context)

@Composable
fun FixedCallbackRegistrationScreen(
    viewModel: MyViewModelWithCallbackRegistry
) {
    val context = LocalContext.current

    // If the Activity changes (configuration change), keep using the latest context
    // without re-registering the callback unnecessarily.
    val latestContext = rememberUpdatedState(context)

    DisposableEffect(viewModel) {
        val onMessageCallback: (String) -> Unit = { msg ->
            Toast.makeText(latestContext.value, msg, Toast.LENGTH_SHORT).show()
        }

        viewModel.registerOnMessageCallback(onMessageCallback)

        onDispose {
            viewModel.unregisterOnMessageCallback(onMessageCallback)
        }
    }

    Button(onClick = { viewModel.emitMessage("Hello from ViewModel") }) {
        Text("Emit message")
    }
}

4) Leak Pattern: Storing composable lambdas (or composition objects) in a ViewModel

Leaky code

class LeakyComposableStorageViewModel : ViewModel() {
    // ❌ Storing composable lambdas is a hard "don't"
    private var storedComposable: (@Composable () -> Unit)? = null

    fun storeComposable(content: @Composable () -> Unit) {
        storedComposable = content
    }

    fun renderStoredComposable() {
        // Imagine some trigger calls it later...
        // (Even having this reference is enough to retain composition state.)
    }
}

@Composable
fun LeakyComposableStoredInViewModelScreen(
    viewModel: LeakyComposableStorageViewModel
) {
    viewModel.storeComposable {
        Text("This composable can capture composition state and context")
    }

    Text("Screen content")
}

Fixed code: store state/events, not UI

data class FixedScreenUiState(
    val title: String = "",
    val isLoading: Boolean = false
)

sealed interface FixedScreenUiEvent {
    data class ShowToast(val message: String) : FixedScreenUiEvent
    data class Navigate(val route: String) : FixedScreenUiEvent
}

class FixedStateDrivenViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(FixedScreenUiState())
    val uiState: StateFlow<FixedScreenUiState> = _uiState.asStateFlow()

    private val _events = MutableSharedFlow<FixedScreenUiEvent>(extraBufferCapacity = 64)
    val events: SharedFlow<FixedScreenUiEvent> = _events.asSharedFlow()

    fun onTitleChanged(newTitle: String) {
        _uiState.value = _uiState.value.copy(title = newTitle)
    }

    fun onSaveClicked() {
        _events.tryEmit(FixedScreenUiEvent.ShowToast("Saved"))
    }
}

@Composable
fun FixedStateDrivenScreen(viewModel: FixedStateDrivenViewModel) {
    val state by viewModel.uiState.collectAsState() // or collectAsStateWithLifecycle()

    // Handle one-off events in UI layer (no UI references stored in VM)
    LaunchedEffect(viewModel) {
        viewModel.events.collect { event ->
            when (event) {
                is FixedScreenUiEvent.ShowToast -> {
                    // UI decides how to show it
                    // (Use LocalContext here; do NOT pass context into ViewModel)
                }
                is FixedScreenUiEvent.Navigate -> {
                    // navController.navigate(event.route)
                }
            }
        }
    }

    Column {
        Text("Title: ${state.title}")
        Button(onClick = viewModel::onSaveClicked) { Text("Save") }
    }
}

5) Leak Pattern: remember without keys (stale resource retention)

Leaky code

class ExpensiveResource(private val id: String) {
    fun cleanup() { /* release */ }
}

@Composable
fun LeakyRememberKeyExample(itemId: String) {
    // ❌ If itemId changes, this still holds the first ExpensiveResource forever (for this composable instance)
    val resource = remember { ExpensiveResource(itemId) }

    Text("Using resource for $itemId -> $resource")
}

Fixed code: key remember + cleanup

@Composable
fun FixedRememberKeyExample(itemId: String) {
    val resource = remember(itemId) { ExpensiveResource(itemId) }

    DisposableEffect(itemId) {
        onDispose { resource.cleanup() }
    }

    Text("Using resource for $itemId -> $resource")
}

6) Migration sleeper leak: ComposeView in Fragments without disposal strategy

If you’re hosting Compose inside a Fragment via ComposeView, you must ensure the composition is disposed with the Fragment’s view lifecycle, not the Fragment instance.

class MyComposeHostFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                Text("Compose hosted in Fragment")
            }
        }
    }
}

Migration leak: Fragment view lifecycle vs Fragment lifecycle (ComposeView)

7) Debugging Compose leaks: a minimal, repeatable flow

Memory Profiler (heap dump approach)

  1. Navigate to LeakyScreenA.
  2. Navigate away so it’s removed (pop back stack if needed).
  3. Force GC, then take a heap dump.
  4. Search for:
  • your Activity name
  • ComposeView
  • Recomposer / Composition / CompositionImpl
  1. Inspect the reference chain:
  • Look for ViewModel, singleton, callback registry, static field, global coroutine jobs.

LeakCanary (what to watch for)

  • Retained Activity or Fragment with a chain through a callback/lambda.
  • Retained ComposeView or composition classes held by a static field.

8) Rules that prevent 95% of Compose leaks

  1. **If you register it, you must unregister it
    Use DisposableEffect(owner).
  2. **Never store composable lambdas or UI objects in ViewModels/singletons
    Store *state* (StateFlow) and events (SharedFlow) instead.
  3. Avoid GlobalScope and app-wide scopes for UI work n Use LaunchedEffect or viewModelScope depending on ownership.
  4. Key your remember n If the object depends on X, use remember(X).
  5. Be careful with Context n Don’t capture an Activity context into long-lived callbacks. Use rememberUpdatedState or redesign so the UI handles UI.

Final takeaway

Compose is not the villain. Your leaks are almost always one of these:

  • Long-lived owner (VM/singleton) holds a UI lambda
  • Registered callback not unregistered
  • Global coroutine captures UI
  • Unkeyed remember retains stale resources
  • ComposeView composition outlives Fragment view

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.