Why Memory Leaks Matter in Jetpack Compose
After shipping six production apps on the Play Store and working with thousands of Android developers, I can tell you that memory leaks are one of the most insidious performance killers in Android development. They don't crash your app—they slowly strangle it.
When I was leading the Android team at CodeBrew Labs, we had a beautiful app with a 4.8-star rating that started degrading after 30 minutes of use. Users complained about lag, jank, and eventual crashes. The culprit? Memory leaks in our Jetpack Compose UI layer that accumulated over time.
Jetpack Compose makes UI development faster and more intuitive, but it also introduces new ways to leak memory if you're not careful. The declarative nature of Compose means you're recomposing constantly, and each recomposition is an opportunity to accidentally hold onto references that should be garbage collected.
"Memory leaks don't announce themselves. By the time users notice, your app has already lost their trust."
Common Memory Pitfalls in Jetpack Compose
In my experience, most memory issues in Compose stem from a handful of predictable patterns. Let me walk you through the ones I've debugged most often.
1. Holding Context References in Composables
This is the classic mistake. Composables are recomposed frequently, and if you're capturing a Context reference directly, you're keeping the entire Activity (or Fragment) in memory long after it should be destroyed.
// ❌ BAD: Context reference leaks the Activity
@Composable
fun MyScreen(context: Context) {
val apiKey = remember { context.getString(R.string.api_key) }
// ...
}
// ✅ GOOD: Use LocalContext or dependency injection
@Composable
fun MyScreen() {
val context = LocalContext.current
val apiKey = remember { context.getString(R.string.api_key) }
// Even better: inject via ViewModel or service locator
}2. Lambdas Capturing Large Objects
When you pass a lambda callback to a child composable, that lambda captures variables from its scope. If those variables are large objects, they stay in memory for the lifetime of the lambda.
// ❌ BAD: Lambda captures the entire viewModel state
@Composable
fun UserList(viewModel: UserViewModel) {
val users = viewModel.users.collectAsState()
LazyColumn {
items(users.value) { user ->
UserCard(
user = user,
onDelete = { viewModel.deleteUser(user) } // Captures viewModel
)
}
}
}
// ✅ GOOD: Extract only what you need
@Composable
fun UserList(viewModel: UserViewModel) {
val users = viewModel.users.collectAsState()
val onDelete: (String) -> Unit = remember { { userId -> viewModel.deleteUser(userId) } }
LazyColumn {
items(users.value) { user ->
UserCard(
user = user,
onDelete = { onDelete(user.id) }
)
}
}
}3. Forgetting to Clean Up in DisposableEffect
When you register listeners, observers, or callbacks in Compose, you must unregister them. DisposableEffect is your safety net, but forgetting the cleanup block is a common leak.
// ❌ BAD: Listener never unregistered
@Composable
fun LocationTracker() {
DisposableEffect(Unit) {
val listener = LocationListener { /* ... */ }
locationManager.requestLocationUpdates(listener)
onDispose { } // Empty cleanup!
}
}
// ✅ GOOD: Clean up in onDispose
@Composable
fun LocationTracker() {
DisposableEffect(Unit) {
val listener = LocationListener { /* ... */ }
locationManager.requestLocationUpdates(listener)
onDispose {
locationManager.removeUpdates(listener)
}
}
}4. Infinite State Flows or Uncancelled Coroutines
If you collect a Flow or launch a coroutine without proper scope management, it can continue running after the composable leaves the composition tree.
// ❌ BAD: Coroutine might outlive the composable
@Composable
fun DataScreen(viewModel: DataViewModel) {
LaunchedEffect(Unit) {
while (true) {
delay(1000)
viewModel.fetchData() // Never cancels if composable is removed
}
}
}
// ✅ GOOD: Use proper scope and cancellation
@Composable
fun DataScreen(viewModel: DataViewModel) {
LaunchedEffect(Unit) {
viewModel.startPolling() // Returns Job, respects scope
}
}Understanding Composition Lifecycle
To prevent memory leaks in Jetpack Compose, you need to deeply understand when composables enter and leave the composition tree.
A composable goes through three phases:
- Composition: The composable is added to the tree. Initialization code runs here.
- Recomposition: State changes, and the composable updates. Most of your code runs multiple times.
- Disposal: The composable is removed from the tree. Cleanup code runs here.
The key insight: Anything you set up during composition must be cleaned up during disposal. If you register a listener, start a coroutine, or hold a reference, you must reverse it.
This is why remember, DisposableEffect, and LaunchedEffect are so important. They're not just conveniences—they're your leak prevention toolkit.
Practical Patterns to Prevent Leaks
Pattern 1: Use ViewModels with Proper Scope
ViewModels are lifecycle-aware and survive configuration changes. They're the right place to hold long-lived resources.
class UserViewModel : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
init {
viewModelScope.launch {
fetchUsers() // Respects ViewModel lifecycle
}
}
private suspend fun fetchUsers() {
try {
val data = apiService.getUsers()
_users.value = data
} catch (e: Exception) {
// Handle error
}
}
override fun onCleared() {
super.onCleared()
// All coroutines automatically cancelled here
}
}Pattern 2: Leverage remember for Expensive Computations
remember caches values across recompositions, preventing unnecessary recreations and reducing memory churn.
@Composable
fun ExpensiveList(items: List<String>) {
val sortedItems = remember(items) {
items.sorted() // Only recomputed when items changes
}
LazyColumn {
items(sortedItems) { item ->
Text(item)
}
}
}Pattern 3: Use LaunchedEffect for Side Effects
LaunchedEffect respects the composition lifecycle and cancels automatically when the composable leaves the tree.
@Composable
fun AutoRefreshScreen(viewModel: ViewModel) {
LaunchedEffect(Unit) {
while (currentCoroutineContext().isActive) {
viewModel.refresh()
delay(10000) // Refresh every 10 seconds
}
}
}Pattern 4: Prefer StateFlow Over LiveData
StateFlow is a Flow, and Flows integrate seamlessly with Compose's lifecycle awareness. LiveData works, but StateFlow is more idiomatic.
// ✅ GOOD: StateFlow in ViewModel
class MyViewModel : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
}
// In Compose
@Composable
fun MyScreen(viewModel: MyViewModel) {
val state = viewModel.state.collectAsState()
// Compose handles subscription lifecycle
}Detecting Leaks Before Production
Prevention is better than cure, but you still need to detect leaks before they ship.
Use LeakCanary in Development
LeakCanary is the gold standard for detecting memory leaks in Android. Add it to your debug build and run your app through common user journeys.
// In build.gradle
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
}Monitor Memory with Android Profiler
The Android Profiler in Android Studio gives you real-time memory usage. Watch for steadily increasing memory that doesn't drop after garbage collection—that's usually a leak.
Write Tests for Lifecycle Cleanup
Unit tests can verify that your composables clean up properly. Test that DisposableEffect callbacks are invoked when expected.
Manual Testing Checklist
- Navigate to a screen and back repeatedly. Memory should stabilize, not grow.
- Rotate the device. Listeners should reinitialize cleanly.
- Minimize and restore the app. State should survive, but listeners shouldn't duplicate.
- Run under memory constraints. Use Android Studio's simulator settings.
📖 Pro Tip
In my experience at CodeBrew Labs, rotating the device 5 times rapidly is the best manual test for memory leaks. Configuration changes stress your lifecycle code hard, and leaks become obvious.
Key Takeaways
- Memory leaks in Jetpack Compose kill user experience silently. They don't crash; they degrade performance until users abandon your app.
- Use remember, DisposableEffect, and LaunchedEffect correctly. These are your primary tools for lifecycle-aware resource management in Compose.
- Hold long-lived resources (like database queries, network clients, location listeners) in ViewModels, not in composables. ViewModels are lifecycle-aware and handle cleanup automatically.
- Always clean up side effects. If you register a listener, start a coroutine, or capture a reference, unregister/cancel it in onDispose or rely on scoped builders like LaunchedEffect.
- Test aggressively with LeakCanary and the Android Profiler. Memory leaks are easy to overlook in code review but obvious when you watch the profiler. Catch them before your users do.
⚠️ Common Mistake
The most frequent leak I've seen in production apps: developers passing the entire ViewModel or Context to child composables when they only need a small piece of data. This inflates the reference graph unnecessarily. Pass only the data you need, or use state hoisting to keep references local.
Memory management in Jetpack Compose isn't complicated—it's just different from imperative Android development. Once you internalize the composition lifecycle and use the right tools (remember, DisposableEffect, LaunchedEffect, ViewModels), leaks become rare.
I've debugged hundreds of memory issues across my apps and client projects. The ones that shipped to users were always preventable. Use these patterns, test with the profiler, and your users will enjoy smooth, responsive apps.