SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:39
UP00:01
MOD · ARTICLE · PERFORMANCES/N · AX-ANDROIPUBLISHED
All Articles
Performance · Updated 13 MIN

Hunting Memory Leaks in Android: LeakCanary, Profiler, and Production Monitoring

Detect, diagnose, and eliminate Android memory leaks with LeakCanary, Android Studio Profiler heap dumps, and production-grade OOM monitoring.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

What is a memory leak on Android, exactly?

The LeakCanary documentation has the cleanest definition: "a memory leak is a programming error that causes an application to keep a reference to an object that is no longer needed. As a result, the memory allocated for that object cannot be reclaimed" (LeakCanary). An Activity after onDestroy() is the standard example. Something still points at it, so it stays. A leak does not crash on the spot. It fails no test and raises no lint warning. The same page describes how it ends: "As small memory leaks accumulate, memory usage grows, the Garbage Collector (GC) runs more frequently and consumes more CPU, causing jank, UI freezes and Application Not Responding (ANR) reports, eventually leading to an OutOfMemoryError (OOME) crash." The earlier version of this article opened with statistics: 22% of stability issues in the top 1,000 apps, a median of 4.7 minutes from leak to crash, a percentage for each cause of leaks, and a finding attributed to two named companies. None of them had a source. The page linked for the first one does not contain it. All of them are removed. One figure does have a source, and it is about one app: "When Square engineers first enabled LeakCanary in the Square Point Of Sale app, they were able to fix several leaks and reduced the OOM crash rate by 94%." The documented common causes are three, all lifecycle bugs: 1. "Adding a Fragment instance to the backstack without clearing that Fragment's view fields in Fragment.onDestroyView()". 2. "Storing an Activity instance as a Context field in an object that survives activity recreation due to configuration changes." 3. "Registering a listener, broadcast receiver or RxJava subscription which references an object with lifecycle, and forgetting to unregister when the lifecycle reaches its end."

LeakCanary: Automatic Leak Detection in Debug Builds

LeakCanary is one dependency: "That's it, there is no code change needed!" The stable release is 2.14. A 3.0 line exists as alphas, the latest being 3.0-alpha-9 from 25 June 2026 (releases). Stay on 2.14 unless you want to test the alpha. Out of the box it watches five kinds of object: destroyed Activity instances, destroyed Fragment instances, destroyed fragment View instances, cleared ViewModel instances and destroyed Service instances. The earlier version of this article added root views removed from the window manager to that list. The getting started page does not. The mechanism is worth knowing, because it explains the delay you see (how it works): - Five seconds. "If the weak reference held by ObjectWatcher isn't cleared after waiting 5 seconds and running garbage collection, the watched object is considered retained, and potentially leaking." - A threshold. "The default threshold is 5 retained objects when the app is visible, and 1 retained object when the app is not visible." One leaked screen does not trigger a heap dump while you are using the app. Put the app in the background and it will. You can watch anything with a lifecycle of your own.
kotlin
// build.gradle.kts (app module)
dependencies {
    // debugImplementation: LeakCanary should only run in debug builds
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

// Confirm it is running: filter Logcat on the LeakCanary tag.
// D LeakCanary: LeakCanary is running and ready to detect leaks

// Watching an object LeakCanary does not know about:
class ImageCache : Closeable {
    override fun close() {
        // After close(), nothing should hold this object
        AppWatcher.objectWatcher.watch(this, "ImageCache was closed")
    }
}

Reading Leak Traces Like a Detective

LeakCanary's guide to fixing a leak is four steps: "Find the leak trace. Narrow down the suspect references. Find the reference causing the leak. Fix the leak." It is honest about the split: "LeakCanary helps you with the first two steps. The last two steps are up to you!" A leak trace is "the best strong reference path from garbage collection roots to the retained object". It reads top down: the root at the top, the object that should be gone at the bottom, one reference per line. LeakCanary marks each object Leaking: YES, NO or UNKNOWN, and underlines the references it suspects with ~~~. The narrowing is deduction from what LeakCanary knows about lifecycles, and the guide walks through it on the trace below. Two facts do the work. "In an Android app the Application instance is a singleton that is never garbage collected", so the reference into it is not the cause. "Views should not survive the lifecycle of their context", so the TextView is leaking and the reference out of it is not the cause either. What stays underlined is the short list: three references, all on the path from the Application's list to the view. One piece of advice from the earlier version of this article has to be withdrawn. Both the text and the FAQ suggested a WeakReference as a fix. LeakCanary's documentation says the opposite: "Memory leaks cannot be fixed by replacing strong references with weak references. It's a common solution when attempting to quickly address memory issues, however it never works." Clear the reference at the right point in the lifecycle instead.
kotlin
// A leak trace, from the LeakCanary documentation:
//
// ┬───
// │ GC Root: System class
// │
// ├─ android.provider.FontsContract class
// │    ↓ static FontsContract.sContext
// ├─ com.example.leakcanary.ExampleApplication instance
// │    Leaking: NO (Application is a singleton)
// │    ↓ ExampleApplication.leakedViews
// │                         ~~~~~~~~~~~
// ├─ java.util.ArrayList instance
// │    ↓ ArrayList.elementData
// │                ~~~~~~~~~~~
// ├─ java.lang.Object[] array
// │    ↓ Object[].[0]
// │               ~~~
// ├─ android.widget.TextView instance
// │    Leaking: YES (View.mContext references a destroyed activity)
// │    ↓ TextView.mContext
// ╰→ com.example.leakcanary.MainActivity instance
//
// The cause is the first underlined reference,
// ExampleApplication.leakedViews: an object that lives as long as
// the process, holding a view that belongs to one Activity.

Android Studio Profiler: Heap Dumps

LeakCanary finds the leaks it is watching for. The profiler in Android Studio shows you the whole heap. The documentation describes the use: "Capture a heap dump to see which objects in your app are using up memory at the time of the capture and identify memory leaks". On timing it adds: "It's especially helpful to take heap dumps after an extended user session, when it could show objects still in memory that should no longer be there." Two controls do most of the work once the dump is open: - The class filter. One of its options is "Show activity/fragment leaks: Shows classes that are causing memory leaks." - Retained Size. Defined as the "Total size of memory being retained due to all instances of this class (in bytes)." Sort by it to see what a leak is costing. A practical routine: open the suspect screen and leave it several times, force a garbage collection, then capture the dump. A screen you left should have no instances. If the dump shows several, each one is a leak. The earlier version of this article listed specific menu items and button labels for this workflow. They were written from memory and are not on the page it linked, so they are removed.

Common Leak Patterns and Fixes

These three patterns are the three documented causes from the first section, in code. Learn to see them in review. The earlier version of this article had a fourth: a Compose remember block whose lambda captured a Context, described as holding the Activity "forever". No source supports that description and the pattern is removed. The fix in each case below is the same idea: end the reference when the lifecycle ends. For coroutines, a scope tied to the lifecycle does it for you. The reference describes lifecycleScope as a "CoroutineScope tied to this LifecycleOwner's Lifecycle" (Android), and the rules behind that are in the coroutines guide.
kotlin
// PATTERN 1: Fragment view fields kept after onDestroyView()
// BAD: the binding holds the whole view tree while the
// Fragment waits on the back stack
class ProfileFragment : Fragment() {
    private var binding: FragmentProfileBinding? = null

    // FIX: clear view fields when the view is destroyed
    override fun onDestroyView() {
        super.onDestroyView()
        binding = null
    }
}

// PATTERN 2: an Activity stored in an object that outlives it
// BAD: the ViewModel survives a configuration change,
// the Activity it points at does not
class MainViewModel : ViewModel() {
    lateinit var activity: Activity
}
// FIX: pass data down. Never store an Activity, a View or a
// Context from one in a ViewModel or a singleton.

// PATTERN 3: a listener that is never unregistered
// BAD: the singleton now holds the Fragment
object EventBus {
    private val listeners = mutableListOf<EventListener>()
    fun register(listener: EventListener) { listeners.add(listener) }
    fun unregister(listener: EventListener) { listeners.remove(listener) }
}

class MyFragment : Fragment(), EventListener {
    override fun onStart() {
        super.onStart()
        EventBus.register(this)
    }

    // FIX: unregister at the matching end of the lifecycle
    override fun onStop() {
        super.onStop()
        EventBus.unregister(this)
    }
}

Production Monitoring: What You Can Actually See

Debug tools find leaks on your devices. Production is different, and the first problem is that the crash you are looking for may not look like one crash. LeakCanary's documentation explains: "When memory is low because of memory leak accumulation, an OOM can be thrown from anywhere in the app code, which means that every OOM has a different stacktrace." A thousand out-of-memory crashes show up as a thousand small ones. Many leaks never throw at all. The system removes the app first: "The Android low memory killer (LMK) daemon process monitors the memory state of a running Android system and reacts to high memory pressure by killing the least essential processes" (Android). To the user that is an app that restarts when they come back to it. Two things you can do, both documented: 1. Ask the system why the process died. ActivityManager.getHistoricalProcessExitReasons returns "a list of ApplicationExitInfo records containing the reasons for the most recent app deaths" (reference). One of the reasons is REASON_LOW_MEMORY. Count those on the next launch and send the count to your analytics. 2. Run LeakCanary in release, knowingly. There is an artifact for it, leakcanary-android-release, and its page carries a warning you should read before you ship it: "Everything about this is experimental." The earlier version of this article recommended logging PSS every 60 seconds, alerting at 150% of a per-screen budget and enabling release detection for 1 to 5% of users. Those were our numbers with no measurement behind them, and they are removed. Low memory kills also feed the stability data Google Play shows you; the Android vitals guide covers how that is scored.
kotlin
// On launch: how many recent deaths were low memory kills?
fun lowMemoryKills(context: Context): Int {
    val activityManager =
        context.getSystemService(ActivityManager::class.java)

    // pid 0 and maxNum 0 mean "do not filter": all records
    val exits = activityManager.getHistoricalProcessExitReasons(
        context.packageName, 0, 0
    )

    return exits.count {
        it.reason == ApplicationExitInfo.REASON_LOW_MEMORY
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1A memory leak is a reference kept to an object that is no longer needed. Most are lifecycle bugs.
  2. 2LeakCanary 2.14 needs one debugImplementation line and watches Activities, Fragments, fragment Views, ViewModels and Services.
  3. 3It waits 5 seconds, then dumps the heap at 5 retained objects while the app is visible, or 1 when it is not.
  4. 4Read a leak trace top down and use the Leaking marks to rule references out.
  5. 5A WeakReference does not fix a leak. Clear the reference when the lifecycle ends.
  6. 6In production, count REASON_LOW_MEMORY exits. Out-of-memory crashes scatter across stack traces and low memory kills are not crashes at all.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

How do I know if my app has a memory leak?

Add LeakCanary to the debug build and use the app. It watches destroyed Activities, Fragments, fragment Views, cleared ViewModels and destroyed Services, and reports any that are still retained after five seconds and a garbage collection. In production, count process exits with REASON_LOW_MEMORY, because a leaking app is often killed before it crashes.

What is the most common cause of a memory leak on Android?

LeakCanary documents three: a Fragment on the back stack that keeps its view fields after onDestroyView, an Activity stored as a Context in an object that survives configuration changes, and a listener or subscription that is never unregistered. All three are the same bug, a reference that outlives the lifecycle of what it points at.

Can I fix a memory leak with a WeakReference?

No. The LeakCanary documentation states that memory leaks cannot be fixed by replacing strong references with weak references and that the approach never works. The bug that kept the reference alive is still there, and objects can now be collected too early. Clear the reference at the correct point in the lifecycle.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA