What are the most common memory leaks in Android apps, and how do you spot them?
Most Android leaks are ordinary strong references — a static field, a listener, a lambda — that quietly outlive the Activity they point back to.
LEARN BY DOINGPut your intuition to work3 steps
What are the most common memory leaks in Android apps, and how do you spot them?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.A memory leak in a garbage-collected runtime like ART doesn't mean memory "disappears" — it means an object that should be eligible for collection is still reachable from a GC root, so the collector is obligated to keep it. In Android specifically, the objects that make this expensive are Activity, Fragment, View, and Context instances, because each one is the root of a large retained graph — an inflated view hierarchy, adapters, bitmaps, listeners — so leaking one destroyed Activity can easily pin several megabytes, and leaking one on every rotation turns "a bit of waste" into an OutOfMemoryError a few navigations later.
The pattern behind almost every Android leak is the same shape: something that lives longer than the component holds a reference back to it. The most common vehicle is a singleton — an object in Kotlin, a static field, a dependency-injection singleton scope — storing a Context, a View, or a listener whose lambda implicitly captures this@Activity. Singletons live for the process's lifetime, so whatever they hold, directly or through a chain of fields, lives that long too, regardless of what onDestroy() does.
A second common vehicle is a registered callback that's never unregistered: a LocationListener or SensorEventListener left running, a LiveData observed with observeForever() instead of observe(lifecycleOwner) (the lifecycle-aware overload removes the observer automatically on destroy; the "forever" one very much means it), an EventBus subscription, an RxJava Disposable never added to a CompositeDisposable that gets cleared in onDestroy(), or a BroadcastReceiver registered in code that's missing its matching unregisterReceiver().
A third, subtler vehicle is delayed work: Handler.postDelayed() called from a non-static inner class captures the enclosing Activity implicitly, and if the user navigates away before the delay elapses, that Runnable — and the Activity behind it — sits in the message queue for the full delay, or forever if the handler is tied to something longer-lived. The coroutine equivalent is a GlobalScope.launch { ... } (or any scope that isn't cancelled on destroy) whose lambda closes over a View or Activity; unlike viewModelScope or lifecycleScope, nothing ever cancels it, so the capture lives until the coroutine itself finishes.
LeakCanary is the standard way to catch these automatically in debug builds: it installs via an auto-init ContentProvider, hooks activity and fragment lifecycle callbacks, and the moment a component is destroyed it watches the instance with a weak reference tied to a ReferenceQueue. If the instance hasn't been reclaimed after a couple of forced GCs, LeakCanary dumps the heap and runs Shark to compute the shortest strong reference path from a GC root to the leaked object — exactly the chain you need to read to fix it. The Android Studio Memory Profiler does the manual version of the same idea: take a heap dump, navigate to a suspected screen and back a few times, take another dump, and diff instance counts of your Activity/Fragment classes — if the count keeps climbing instead of returning to zero, something is holding on.
Here's a realistic version of the singleton-listener leak, and the lifecycle-aware fix. The singleton keeps a list of listener lambdas and a raw LocationListener registration that's never removed — every lambda captures its enclosing Activity implicitly through the method call inside it.
// LEAK: a process-lifetime singleton retains an Activity through a// registered listener that is never removed.object LocationTracker {private val listeners = mutableListOf<(Location) -> Unit>()private lateinit var locationManager: LocationManagerprivate val rawListener = LocationListener { location ->listeners.forEach { it(location) }}fun start(context: Context, onLocation: (Location) -> Unit) {listeners += onLocationlocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManagerlocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 5_000L, 10f, rawListener)}}class MapActivity : AppCompatActivity() {override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)LocationTracker.start(this) { location -> updateMapPin(location) } // captures this@MapActivity}}
LocationTracker outlives every MapActivity instance by design — it's a singleton — so the lambda sitting in listeners, and the MapActivity it silently captures to call updateMapPin, are retained forever, or until the process dies. Rotate the device ten times and you leak ten Activities.
// FIXED: registration and teardown are tied to the same lifecycle,// and the Activity is never handed to anything longer-lived than itself.class MapActivity : AppCompatActivity(), DefaultLifecycleObserver {private val locationManager by lazy {getSystemService(Context.LOCATION_SERVICE) as LocationManager}private val locationListener = LocationListener { location -> updateMapPin(location) }override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)lifecycle.addObserver(this)}override fun onStart(owner: LifecycleOwner) {locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 5_000L, 10f, locationListener)}override fun onStop(owner: LifecycleOwner) {locationManager.removeUpdates(locationListener) // symmetric teardown}}
The fix has two ideas doing the work: nothing longer-lived than the Activity ever holds a direct reference to it (the listener lives on the Activity itself, not in a singleton), and every onStart has a matching onStop that undoes it — registration and teardown are symmetric in the same lifecycle pair, not spread across onCreate/onDestroy where it's easy to forget the second half.
Beyond this specific pattern, the checklist that prevents most Android leaks is short: prefer applicationContext over an Activity context in anything that might outlive the screen; treat WeakReference as a last resort, not a first instinct — it usually means you skipped fixing the actual lifetime mismatch; tie every coroutine scope to viewModelScope or lifecycleScope instead of GlobalScope; and audit every registerX/addListener call for a matching unregisterX/removeListener in the mirror lifecycle callback.