Memory leaks in Android: real-world lessons and examples
Memory leaks are one of those silent killers in Android apps that don't immediately crash but gradually degrade performance and user experience. We've all seen the dreaded ANR dialog or felt that sluggish scroll in a production app where memory pressure kept climbing. Understanding memory leaks in Android: real-world lessons and examples is essential because the patterns that cause them repeat across teams and projects, and the fixes are often straightforward once you know what to look for.
What happens when memory leaks in Android
A memory leak occurs when your code holds a reference to an object that should have been garbage collected. In Android's context, this is especially problematic because:
- Activities and Fragments are heavyweight objects tied to the UI lifecycle
- Heap size is limited (often 512 MB on mid-range devices)
- Once the heap fills, the garbage collector runs more frequently, causing visible jank
- Eventually, you hit OutOfMemoryError and the app crashes
The insidious part is that a leak might only become apparent after the user navigates through your app a dozen times, making it hard to reproduce in QA.
LeakCanary detects leaks in debug builds
LeakCanary is your first line of defense. This tool, created by the Square team, automatically detects memory leaks in your debug builds by monitoring object lifecycles and dumping the heap when leaks are suspected.
Setting it up:
// In your build.gradle.kts
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}That's all you need. LeakCanary runs automatically in debug builds and shows a notification when it detects a leak. Tap it to see the reference chain, which tells you exactly what's holding the object in memory.
A real-world scenario: we built a payment flow with a custom dialog that dismissed after a transaction completed. Without LeakCanary, we wouldn't have caught that our dialog's callback was holding a reference to the Activity through a non-static inner class. LeakCanary flagged it immediately, and we refactored to use a separate component or made the class static.
LeakCanary also integrates with your CI/CD pipeline. You can fail builds if leaks exceed a threshold, catching problems before they reach production.
Do not hold Activity references in singletons
One of the most common patterns we see is an Activity reference stored in a singleton or a long-lived object. Singletons persist for the life of the process, so if they hold an Activity reference, that Activity can never be garbage collected even after the user navigates away.
The anti-pattern:
object UserManager {
var currentActivity: Activity? = null
fun logoutUser() {
// This Activity reference won't be released until process death
currentActivity?.showToast("Logged out")
}
}When the user rotates the device or closes the Activity, a new Activity instance is created, but the old one is held by UserManager and never freed. Multiply this across a few rotations or navigation events, and you've leaked multiple Activity instances.
The fix:
// Use a WeakReference if you absolutely need a reference
object UserManager {
private var currentActivityRef: WeakReference? = null
fun setCurrentActivity(activity: Activity) {
currentActivityRef = WeakReference(activity)
}
fun logoutUser() {
// Get the Activity if it still exists
val activity = currentActivityRef?.get()
activity?.showToast("Logged out")
}
}Better approach:
// Use a callback or event bus instead
object UserManager {
private val logoutEvents = MutableLiveData()
fun observeLogoutEvents(): LiveData = logoutEvents
fun logoutUser() {
// No Activity reference needed
logoutEvents.postValue(Unit)
}
}
// In your Activity
class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
UserManager.observeLogoutEvents().observe(this) {
showToast("Logged out")
navigateToLogin()
}
}
}The event-based approach decouples your singleton from UI components and eliminates the reference entirely. This is the pattern we recommend across teams.
Cancel coroutines with lifecycle scopes
Coroutines are powerful but can easily leak if not scoped correctly. A coroutine that's launched on GlobalScope or lifecycleScope without proper cancellation will hold references to its context, including the Activity.
The anti-pattern:
class UserDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// This coroutine won't be cancelled when the Activity is destroyed
GlobalScope.launch {
val user = fetchUser()
textView.text = user.name
}
}
}If fetchUser() takes 10 seconds and the user closes the Activity after 2 seconds, the coroutine continues running in the background. It holds a reference to the Activity (via the captured this and textView), preventing garbage collection.
The fix:
class UserDetailActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Coroutine is tied to the Activity's lifecycle
lifecycleScope.launch {
val user = fetchUser()
textView.text = user.name
}
}
}lifecycleScope automatically cancels all coroutines when the Activity is destroyed. The coroutine won't try to update the UI after the Activity is gone, and the reference is released.
For more fine-grained control, tie coroutines to a specific lifecycle state:
lifecycleScope.launch(Dispatchers.Main) {
// Only collect when the Activity is at least resumed
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel.uiState.collect { state ->
renderUI(state)
}
}
}This pattern is especially important when working with Flows or collecting from LiveData. Always use lifecycleScope or a ViewModel scope, never GlobalScope.
Unregister listeners
Listeners, callbacks, and broadcast receivers are callback-based APIs that hold implicit references to their owner. If you register but never unregister, you leak the Activity or Fragment.
The anti-pattern:
class SensorDataActivity : AppCompatActivity() {
private lateinit var sensorManager: SensorManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
// Registered but never unregistered
sensorManager.registerListener(object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
updateAccelerometerUI(event)
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}, accelerometer)
}
}The anonymous SensorEventListener captures this (the Activity). Even after the Activity is destroyed, the SensorManager still holds a reference to the listener, which holds a reference to the Activity.
The fix:
class SensorDataActivity : AppCompatActivity() {
private lateinit var sensorManager: SensorManager
private var sensorListener: SensorEventListener? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
sensorListener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
updateAccelerometerUI(event)
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorManager.registerListener(sensorListener, accelerometer)
}
override fun onDestroy() {
super.onDestroy()
// Always unregister
if (sensorListener != null) {
sensorManager.unregisterListener(sensorListener)
}
}
}The same applies to SharedPreferences listeners, content observers, and any EventBus subscriptions. Register in onCreate (or onStart for fragments), unregister in onDestroy (or onStop).
For Fragments, be extra careful:
class SensorFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Register in onViewCreated
registerSensorListener()
}
override fun onDestroyView() {
// Unregister in onDestroyView, not onDestroy
unregisterSensorListener()
super.onDestroyView()
}
}Fragments can outlive their Views, so unregister when the View is destroyed, not the Fragment itself.
Common mistakes
- Non-static inner classes: Inner classes automatically hold a reference to their outer class. If you pass a non-static inner class to a callback or thread, it leaks the outer Activity. Make them static and pass the Activity as a WeakReference if needed.
- Handler posts without cancellation: A Handler posting delayed messages holds references to the Runnable. If the Activity is destroyed before the Runnable executes, you leak. Always remove callbacks in onDestroy:
handler.removeCallbacksAndMessages(null) - Retained Fragments: If you call
setRetainInstance(true)on a Fragment (now deprecated in favor of ViewModel), the Fragment survives configuration changes but may hold stale Activity references. - Unobserved LiveData or Flow: If a ViewModel exposes a LiveData but the Activity/Fragment never observes it with a lifecycle-aware observer, the ViewModel holds a reference to any objects it's collecting from, which may include the Activity indirectly.
- Context in utility classes: Passing
this(Activity context) to utility classes or passingapplicationContextto singletons can leak. PreferapplicationContextfor long-lived objects, or use dependency injection to avoid holding references at all.
Key takeaways
- Add LeakCanary to your debug builds immediately and review leaks regularly. It's the fastest way to catch and fix memory issues before they reach production.
- Never store Activity or Fragment references in singletons, static fields, or long-lived objects. Use WeakReference if absolutely necessary, or better yet, use events or callbacks to decouple your components.
- Always use lifecycleScope for coroutines and lifecycle-aware observers for LiveData. Tie coroutine lifetimes to the UI component that uses them.
- Unregister all listeners, callbacks, and observers in onDestroy or onDestroyView. Make it a habit to create a pair: if you register in onCreate, unregister in onDestroy.
- Be mindful of inner classes and threads. Prefer static inner classes or pass WeakReferences to ensure the Activity can be garbage collected.
- Use applicationContext for singletons and Manager classes. If you need Context, use the application context, not the Activity context.






Comments
Loading comments…