Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 053 / 100

Memory leaks in Android: real-world lessons and examples

Find and fix leaks.

Expona Admin8 min read

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:

kotlin
// 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:

kotlin
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:

kotlin
// 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:

kotlin
// 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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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:

kotlin
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 passing applicationContext to singletons can leak. Prefer applicationContext for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles