Memory 14 min read

Memory Leaks in Android: How to Detect, Fix, and Never Let Them Slip Again

A memory leak in Android is deceptively simple: an object that is no longer needed stays in memory because something still holds a reference to it. The GC can't collect it. Over time, your heap fills up, your app gets slower, and eventually you get an OutOfMemoryError crash — usually on a mid-range device that a flagship never reproduces.

I've fixed dozens of leaks across production apps. Most of them came down to the same handful of patterns. This post covers all of them — what causes them, how to find them, and exactly how to fix each one.

Memory leaks don't always crash your app. Sometimes they just make it progressively slower and memory-hungry. Users notice before Play Console does — they uninstall before the OOM ever fires.

1. How to Detect Memory Leaks

LeakCanary — your first line of defense

Add LeakCanary to your debug build only. It automatically watches for Activity, Fragment, ViewModel, and View leaks, and shows a notification with the full reference chain when it finds one.

// build.gradle.kts
dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

That's the entire setup. No code changes needed. When LeakCanary detects a leak, it shows you a chain like:

┬───
│ GC Root: Thread
│
├─ MyActivity instance
│    Leaking: YES (Activity#mDestroyed is true)
│    ↓ MyActivity.handler
├─ Handler instance
│    ↓ Handler.callback
├─ MyActivity$1 instance  ← anonymous inner class
│    Retaining 1.4 MB
╰→ MyActivity instance
     Leaking: YES

Read it bottom-up: the retained object is at the bottom, and the reference keeping it alive is at the top. In this case, an anonymous inner class inside a Handler is holding a reference to the Activity after it's been destroyed.

Android Studio Memory Profiler

For leaks LeakCanary doesn't automatically detect (like Bitmap leaks or large data structures), use the Memory Profiler:

  1. Run your app with the Profiler attached (Run → Profile)
  2. Navigate the screen you suspect is leaking, then navigate away
  3. Click Force GC in the profiler toolbar
  4. If memory doesn't drop back down, capture a heap dump
  5. In the heap dump, filter by your package name and look for instances of classes that should have been collected (e.g., destroyed Activities)

Always force GC before reading the memory number. Without it, you're seeing allocated objects — not leaked ones. The profiler number after GC is your real baseline.

2. The Most Common Leak Patterns

Pattern 1 — Static reference to Activity or Context

A static field lives for the lifetime of the process. Assign an Activity to one and that Activity can never be garbage collected, no matter how many times the user rotates the screen or navigates away.

Leaks
object AppManager {
    // Static hold on Activity = leak every time a new Activity is created
    var currentActivity: Activity? = null
}

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        AppManager.currentActivity = this  // NEVER do this
    }
}
Fixed
object AppManager {
    private var activityRef: WeakReference<Activity>? = null

    fun setActivity(activity: Activity) {
        activityRef = WeakReference(activity)
    }

    val currentActivity: Activity?
        get() = activityRef?.get()
}

// And always clear in onDestroy
override fun onDestroy() {
    super.onDestroy()
    if (AppManager.currentActivity === this) {
        AppManager.setActivity(null as Activity)  // or set ref to null
    }
}

Better yet — ask yourself why you need a static reference to an Activity at all. Most of the time, passing applicationContext solves the problem without any reference management.

Pattern 2 — Non-static inner class + Handler

In Kotlin, a lambda or anonymous object defined inside an Activity or Fragment implicitly holds a reference to its outer class. Post it to a Handler with a delay, and the Activity is kept alive until that message fires — even if the user has already left the screen.

Leaks
class MainActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // This lambda captures `this` (MainActivity)
        handler.postDelayed({
            updateUI()  // Activity may be destroyed by the time this runs
        }, 5000)
    }
}
Fixed
class MainActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
    private val updateRunnable = Runnable { updateUI() }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handler.postDelayed(updateRunnable, 5000)
    }

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacks(updateRunnable)  // cancel pending messages
    }
}

In modern Android, you can sidestep this entirely by using lifecycleScope.launch with a delay — the coroutine is automatically cancelled when the lifecycle ends.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    lifecycleScope.launch {
        delay(5000)
        updateUI()  // only runs if the Activity is still alive
    }
}

Pattern 3 — Singleton holding Activity Context

Leaks
class ImageLoader private constructor(private val context: Context) {
    companion object {
        private var instance: ImageLoader? = null
        fun get(context: Context) = instance ?: ImageLoader(context).also { instance = it }
    }
}

// Called from Activity — passes Activity context to a singleton that lives forever
val loader = ImageLoader.get(this)
Fixed
class ImageLoader private constructor(private val context: Context) {
    companion object {
        private var instance: ImageLoader? = null
        fun get(context: Context) =
            instance ?: ImageLoader(context.applicationContext).also { instance = it }
            //                      ^^^^^^^^^^^^^^^^^^^^^^^^^^
            //                      applicationContext outlives any Activity
    }
}

Pattern 4 — Unregistered Listeners and Callbacks

Any time you register a listener — BroadcastReceiver, LocationManager, SensorManager, EventBus, a custom observable — you must unregister it. If you don't, the system holds a reference to your listener, which holds a reference to your Activity.

Leaks
class LocationActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER, 1000, 0f,
            object : LocationListener {
                override fun onLocationChanged(location: Location) {
                    updateMap(location)  // captures LocationActivity implicitly
                }
            }
        )
        // never unregistered
    }
}
Fixed
class LocationActivity : AppCompatActivity() {
    private val locationListener = LocationListener { location ->
        updateMap(location)
    }

    override fun onStart() {
        super.onStart()
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER, 1000, 0f, locationListener
        )
    }

    override fun onStop() {
        super.onStop()
        locationManager.removeUpdates(locationListener)
    }
}

Pattern 5 — ViewModel holding a View or Activity reference

ViewModels survive configuration changes — that's their entire purpose. If a ViewModel holds a reference to a View, Context, or Activity, it's holding a stale reference to the destroyed instance after rotation.

Leaks
class ProfileViewModel(
    private val activity: MainActivity,  // Activity dies on rotation, ViewModel survives
    private val textView: TextView       // View dies too
) : ViewModel()
Fixed
class ProfileViewModel(
    private val application: Application,  // applicationContext is safe
    private val repository: ProfileRepository
) : AndroidViewModel(application) {
    // Expose state via StateFlow — let the Fragment/Activity observe and update its own Views
    val profileState: StateFlow<ProfileUiState> = repository
        .getProfile()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ProfileUiState.Loading)
}

Pattern 6 — Coroutine Scope Leaks

Using GlobalScope launches a coroutine tied to the process, not to any lifecycle. If the coroutine references a View or Activity, both are kept alive until the coroutine finishes — which for a network call could be several seconds after the user has navigated away.

Leaks
class ProfileFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        GlobalScope.launch {  // not tied to Fragment lifecycle
            val data = repository.fetchProfile()
            withContext(Dispatchers.Main) {
                binding.nameText.text = data.name  // binding may be null by now
            }
        }
    }
}
Fixed
class ProfileFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {   // cancelled when view is destroyed
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.profileState.collect { state ->
                    binding.nameText.text = state.name
                }
            }
        }
    }
}

Pattern 7 — ViewBinding Leak in Fragments

A Fragment's view lifecycle is shorter than the Fragment's lifecycle itself. The view is destroyed in onDestroyView, but the Fragment object sticks around (e.g., on the back stack). If you hold a non-null binding reference, you're keeping the entire view hierarchy in memory.

Leaks
class ProfileFragment : Fragment() {
    private lateinit var binding: FragmentProfileBinding  // never nulled
    // Fragment survives on back stack, binding holds the destroyed view hierarchy
}
Fixed
class ProfileFragment : Fragment() {
    private var _binding: FragmentProfileBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentProfileBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null  // release view hierarchy
    }
}

This is the single most common leak in Fragment-based apps. The _binding = null in onDestroyView() is not optional — it's required whenever a Fragment can be on the back stack.

Pattern 8 — Flow Collectors Without Lifecycle Awareness

Leaks
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    // collect{} without repeatOnLifecycle keeps collecting even when UI is in the background
    lifecycleScope.launch {
        viewModel.data.collect { render(it) }
    }
}
Fixed
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.data.collect { render(it) }
        }
    }
}

repeatOnLifecycle(STARTED) automatically cancels collection when the app goes to the background and restarts it when it comes back. Without it, your Flow runs and your lambda holds references even while the screen is invisible.


3. The Prevention Checklist

Run through this checklist on every Fragment and Activity before shipping:

Memory Leak Checklist

  • ViewBinding uses _binding = null in onDestroyView()
  • All listeners registered in onStart() / onResume() are unregistered in onStop() / onPause()
  • BroadcastReceivers registered in code (not manifest) are unregistered in onDestroy()
  • No GlobalScope.launch — use lifecycleScope or viewModelScope
  • Flow collection uses repeatOnLifecycle(STARTED)
  • No Activity or View references stored in a ViewModel
  • Singletons use applicationContext, never Activity context
  • Handler postDelayed callbacks are removed in onDestroy()
  • No static references to Activity, Fragment, View, or Cursor
  • LeakCanary is in debugImplementation and checked before each release

4. One Rule That Catches 80% of Leaks

Before you ship any screen, ask: does anything outlive this screen hold a reference to this screen?

If something with a longer lifetime (a singleton, a global scope coroutine, a static field, a system service) holds a reference to something with a shorter lifetime (an Activity, a Fragment, a View), you have a leak. That's it. Most memory leaks in Android violate exactly this one rule.

LeakCanary will catch what you miss. But understanding the pattern means you stop writing leaks in the first place.

Comments 0

No comments yet. Be the first to leave one!

Leave a comment