How to Avoid Memory Leaks in Jetpack Compose: Real Examples, Causes, and Fixes
"Hey… why does this screen freeze every time I scroll too fast?"
That's what my QA pinged me at 11:30 AM on a perfectly normal Tuesday.
I brushed it off. "Probably a one-off," I thought.
But then the bug reports started trickling in:
- "The app slows down after using it for a while."
- "Navigation feels laggy."
- "Sometimes it just… dies."
That's when the panic set in.
I had recently migrated a big chunk of the UI to Jetpack Compose - Google's shiny, declarative UI toolkit. I was excited. Everything felt clean, modern, and reactive. But something wasn't right. RAM usage kept climbing. The Garbage Collector was working overtime. The app felt like it was slowly drowning, and I couldn't figure out why.
Fast forward to 2:45 AM.
There I was, still staring at Android Studio's memory profiler that looked more like a heart monitor in cardiac arrest. The app hadn't crashed. No red flags in logs. Just a slow, creeping, unexplained death.
And the culprit?
Jetpack Compose. Or rather, how I used Jetpack Compose.
Jetpack Compose didn't break my app. I did - by not understanding how it could leak memory.
If you're working with Compose, or planning to, here's what I wish I knew before that Tuesday meltdown.
What is Jetpack Compose?
Jetpack Compose is Google's modern way of building Android app UIs.
Instead of writing long XML files and manually updating the UI step by step, Compose lets you just say: "Hey, here's what the screen should look like based on the current data."
It's like giving instructions for the final look, and Compose figures out how to make it happen, like React, but made just for Android.
Example:
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
You just say:
"Hey Compose, whenever name changes, update this text to say 'Hello, [name]'."
And Compose listens. Reactively. Automatically. Cleanly.
That's it. Just one function.
- No XML.
- No
findViewById - No manually setting text like
textView.text = …
It's clean, efficient, reactive, and often a developer's dream.
But dreams can have bugs too.
What is a Memory Leak?
A memory leak happens when your app holds onto objects in memory that it no longer needs, preventing them from being garbage collected. Over time, this causes your app's memory usage to keep growing, until things slow down or crash.
In Android, leaks often came from things like:
- Forgetting to unregister listeners
- Holding references to Contexts (like Activities) longer than necessary
- Leaky singletons
- Anonymous inner classes
In Jetpack Compose, the patterns are different, but the problem still exists.
And that's what caught me off guard.
How Jetpack Compose Can Accidentally Leak Memory
Let's go through a few real-world examples, based on the issues I faced, and how I solved them.
1. Lambda Expressions Capturing ViewModels
Let us say you write this:
@Composable
fun TrainerScreen(viewModel: TrainerViewModel) {
Button(onClick = { viewModel.loadWorkout() }) {
Text("Load Workout")
}
}
Looks innocent, right?
But that lambda (onClick = { viewModel.loadWorkout() }) captures the viewModel reference. If this Composable gets recomposed frequently, new lambdas are created each time, but old ones may still hang around, especially if animations or transitions are involved.
Over time, this can prevent the ViewModel or even the entire Composable tree from being garbage collected.
The Fix: Use rememberUpdatedState() to stabilize your lambda references
@Composable
fun TrainerScreen(viewModel: TrainerViewModel) {
val latestViewModel = rememberUpdatedState(viewModel)
Button(onClick = {
latestViewModel.value.loadWorkout()
}) {
Text("Load Workout")
}
}
This makes sure that your lambda doesn't hold onto old references.
2. Not Disposing Side Effects
This one is sneaky.
Imagine you create a database instance inside your Composable:
@Composable
fun DataScreen() {
val context = LocalContext.current
val db = remember {
Room.databaseBuilder(
context,
AppDatabase::class.java,
"app-db"
).build()
}
// use db here...
}
Problem? You're creating a long-lived object (like a DB connection) inside a Composable, and never releasing it. When this Composable goes out of scope, Compose doesn't automatically know to clean that up.
The Fix: Use DisposableEffect
@Composable
fun DataScreen() {
val context = LocalContext.current
DisposableEffect(Unit) {
val db = Room.databaseBuilder(
context,
AppDatabase::class.java,
"app-db"
).build()
onDispose {
db.close()
}
}
}
This ensures proper cleanup when the Composable is removed from the hierarchy.
3. Observers That Never Leave
If you're using LiveData.observeAsState() or observing a Flow, and the observer is not removed when the Composable disappears, it sticks around in memory.
@Composable
fun WorkoutScreen(viewModel: WorkoutViewModel) {
val workout = viewModel.workoutData.observeAsState()
// render workout
}
If this Composable is part of a navigation graph, and you pop it. Guess what? That observer may still be alive if you're not careful.
Better Approach: Use lifecycle-aware collection
@Composable
fun WorkoutScreen(viewModel: WorkoutViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val workout by viewModel.workoutFlow
.flowWithLifecycle(lifecycle)
.collectAsState(initial = null)
// render workout
}
This ensures your state observation respects the Composable's lifecycle.
Bonus Tips to Stay Leak-Free
- Avoid holding
Contextinside long-lived objects unless it'sApplicationContext - Don't use
rememberto hold huge data (like bitmaps or DB instances) - Be mindful of
LaunchedEffect- if it depends on a parameter that changes frequently, it can spawn too many coroutines - Use LeakCanary or Appxiom regularly to track memory issues early
Final Thoughts
Jetpack Compose is powerful, elegant, and developer-friendly, but it's not magic.
You still need to think about memory. You still need to understand lifecycles. And you definitely need to watch out for sneaky references hiding in lambdas, effects, or state holders.
Take it from someone who's been burned:
Memory leaks in Compose don't scream. They whisper. Slowly. Quietly. Until your app feels bloated and unresponsive.
Now you know how to hear them coming.
Happy composing.
