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

Detect, diagnose, and eliminate memory leaks using LeakCanary, heap dumps, and production OOM monitoring.

Introduction

Memory leaks cause OutOfMemoryError crashes and degraded performance. They accumulate over app sessions and are hard to reproduce. This guide covers leak detection with LeakCanary, diagnosis with heap dumps, and production monitoring.

Understanding Android Memory Leaks

Common causes: static references to Context, non-static inner classes, registered listeners not unregistered, coroutine scopes not cancelled. Leaks accumulate until OOM crash.

LeakCanary Setup and Usage

Add LeakCanary dependency. It automatically detects leaks in debug builds. View leak traces in LeakCanary activity. Follow leak traces to find root cause. Fix by breaking reference chain.

Heap Dump Analysis

Capture heap dump with Android Studio Profiler. Analyze with Memory Profiler. Find largest allocations. Identify retained objects. Use dominator tree for leak analysis.

Production Monitoring

Track memory usage with PerformanceStats class. Monitor OOM crash rate in Crashlytics. Set up alerts for memory pressure. Consider custom leak detection for critical paths.

Frequently Asked Questions

How do I know if I have a memory leak?

Signs: increasing memory usage over time, OOM crashes, app slowdown after extended use. Use LeakCanary in development. Monitor memory metrics in production.

What's the most common memory leak?

Static references to Activity or Context. Use application context for long-lived objects. Use WeakReference if you must hold Activity references.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:37:42
UP00:01
MOD · ARTICLE · PERFORMANCES/N · AX-ANDROIPUBLISHED
PerformanceMar 10, 202614 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

The Silent Killer: Why Memory Leaks Ship to Production

Memory leaks are the most insidious category of Android bugs. They don't cause immediate crashes. They don't fail tests. They don't trigger lint warnings. They silently accumulate, gradually increasing memory pressure until the system kills your app with an OOM -- often minutes after the leak was created, on a screen far removed from the offending code. Google's Android Vitals data shows that OOM-related crashes account for 22% of all stability issues in the top 1,000 Play Store apps. The median time from leak creation to OOM crash is 4.7 minutes of active use, making reproduction notoriously difficult. And the impact compounds: memory-stressed apps experience 3x more jank frames because the garbage collector runs more frequently, pausing the main thread. The three most common leak sources in modern Android development are: 1. **Activity/Fragment references held by long-lived objects** (38% of leaks): ViewModels holding View references, static fields pointing to Contexts, or callbacks registered on singletons. 2. **Unregistered listeners and observers** (29%): BroadcastReceivers, LocationListeners, or custom callbacks that outlive their registration scope. 3. **Coroutine scope mismanagement** (19%): GlobalScope launches, lifecycleScope jobs that capture outer references, or Channel consumers that never close. The remaining 14% come from inner classes, Handler references, and third-party SDK leaks.

LeakCanary: Automatic Leak Detection in Debug Builds

LeakCanary is the gold standard for debug-time leak detection. It watches objects that should be garbage collected (Activities, Fragments, Views, ViewModels, Services) and triggers a heap dump when they are retained beyond their expected lifecycle. The library then analyzes the heap dump to find the shortest strong-reference path from the GC root to the retained object -- the "leak trace." Setup is a single dependency line. LeakCanary auto-installs via a ContentProvider and requires zero configuration for standard leak detection.
kotlin
// build.gradle.kts (app module)
dependencies {
    // Only in debug builds -- zero production overhead
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

// That's it. No Application class changes, no init code.
// LeakCanary will:
// 1. Watch all Activities after onDestroy()
// 2. Watch all Fragments after onDestroyView()
// 3. Watch all ViewModels after onCleared()
// 4. Watch all root Views after removal from WindowManager
// 5. Watch all Services after onDestroy()

// For custom watched objects (e.g., a cache you expect to be GC'd):
class ImageCache : Closeable {
    override fun close() {
        // After close(), this object should be GC'd
        AppWatcher.objectWatcher.expectWeaklyReachable(
            watchedObject = this,
            description = "ImageCache was closed"
        )
    }
}

Reading Leak Traces Like a Detective

LeakCanary's leak trace is a path from a GC root to the retained object. Reading it correctly is the difference between a five-minute fix and a day of confusion. The trace reads top-down: GC root at the top, leaked object at the bottom. Each line represents a reference from one object to the next. Key elements in a leak trace: - **Leaking: YES/NO/UNKNOWN**: LeakCanary's assessment of whether each object in the chain should have been collected. - **Retaining X.X kB**: The amount of memory retained solely because of this leak. - **References underlined in red**: The suspicious reference causing the leak. Here's how to interpret a real-world leak trace:
kotlin
// Example LeakCanary output:
//
// ====================================
// HEAP ANALYSIS RESULT
// ====================================
// 1 APPLICATION LEAKS
//
// Signature: a]com.myapp.ui.dashboard.DashboardFragment
// ┬───
// │ GC Root: Thread object
// │
// ├─ java.lang.Thread instance
// │    Thread name: 'OkHttp ConnectionPool'
// │    ↓ Thread.target
// │
// ├─ com.myapp.network.ApiClient$1 instance  // <-- anonymous inner class
// │    Leaking: UNKNOWN
// │    ↓ ApiClient$1.callback
// │                   ~~~~~~~~               // <-- SUSPICIOUS REFERENCE
// ├─ com.myapp.ui.dashboard.DashboardFragment instance
// │    Leaking: YES (Fragment#mHost is null)
// │    Retaining 3.2 MB
// │    key = "DashboardFragment"
// ╰───
//
// FIX: The ApiClient holds a callback reference to DashboardFragment.
//      The callback must be cleared in onDestroyView() or use a
//      WeakReference.

// Before (leaks):
class DashboardFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        apiClient.fetchDashboard(callback = this::onDataLoaded)
        //                                  ^^^^^^^^^^^^^^^^
        //  'this' (the Fragment) is captured by the callback
    }
}

// After (fixed):
class DashboardFragment : Fragment() {
    private var fetchJob: Job? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        fetchJob = viewLifecycleOwner.lifecycleScope.launch {
            val data = apiClient.fetchDashboard()
            onDataLoaded(data)
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        fetchJob?.cancel() // Coroutine is lifecycle-aware
    }
}

Android Studio Profiler: Heap Dumps and Allocation Tracking

LeakCanary is excellent for automatic detection, but Android Studio's Memory Profiler gives you a live, interactive view of your app's heap. It is essential for diagnosing complex leaks that require understanding allocation patterns over time. The workflow for using the profiler effectively: 1. **Establish a baseline**: Open the profiler, navigate to the suspect screen, and note the heap size. 2. **Exercise the leak**: Navigate away from the screen and return to it 5-10 times. If memory grows linearly with each visit, you have a leak. 3. **Capture a heap dump**: Click "Dump Java heap" after exercising the leak. This gives you a snapshot of every live object. 4. **Filter by package**: In the heap dump view, filter to your app's package. Sort by "Retained Size" descending. 5. **Look for duplicates**: If you see 10 instances of a Fragment that should have at most 1, each extra instance is a leak. 6. **Trace the reference**: Right-click any suspicious object and select "Jump to Source" or inspect its reference chain. Pro tip: Use the "Record allocations" feature to see *where* objects are being allocated. This is invaluable for finding leaks caused by repeated creation of objects that are never released -- like listener registrations in `onResume()` without matching de-registrations in `onPause()`.

Common Leak Patterns and Fixes

Over years of production Android development, certain leak patterns appear repeatedly. Memorizing these patterns lets you spot them in code review before they ever reach a device.
kotlin
// PATTERN 1: ViewModel holding a View/Context reference
// BAD -- ViewModel outlives the Activity
class MainViewModel : ViewModel() {
    lateinit var adapter: RecyclerView.Adapter<*> // Holds View references!
}
// FIX: Never store View/Context references in ViewModel.
// Pass data down, not Views up.

// PATTERN 2: Anonymous inner class capturing Activity
// BAD -- the Runnable captures 'this' (the Activity)
class DetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        handler.postDelayed({
            updateUI() // 'this' is captured implicitly
        }, 30_000)
    }
}
// FIX: Cancel the handler in onDestroy or use lifecycleScope
class DetailActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        lifecycleScope.launch {
            delay(30_000)
            updateUI() // Automatically cancelled when destroyed
        }
    }
}

// PATTERN 3: Singleton holding a listener
// BAD -- singleton outlives the Fragment
object EventBus {
    private val listeners = mutableListOf<EventListener>()
    fun register(listener: EventListener) { listeners.add(listener) }
}
class MyFragment : Fragment(), EventListener {
    override fun onStart() {
        super.onStart()
        EventBus.register(this) // Fragment is now held by singleton
    }
    // Missing: EventBus.unregister(this) in onStop()
}
// FIX: Always unregister, or use lifecycle-aware observers

// PATTERN 4: Compose remember with captured reference
// BAD -- the lambda captures a mutable reference
@Composable
fun LeakyScreen(viewModel: LeakyViewModel) {
    val context = LocalContext.current
    val callback = remember {
        // This lambda captures 'context' (the Activity) forever
        { data: String -> Toast.makeText(context, data, Toast.LENGTH_SHORT).show() }
    }
}
// FIX: Use rememberUpdatedState or pass context at call time
@Composable
fun FixedScreen(viewModel: FixedViewModel) {
    val context = LocalContext.current
    val currentContext by rememberUpdatedState(context)
    val callback = remember {
        { data: String -> Toast.makeText(currentContext, data, Toast.LENGTH_SHORT).show() }
    }
}

Production Monitoring: Catching Leaks Before Users Report Them

Debug-time tools catch leaks during development, but some leaks only manifest under specific conditions -- particular device models, OS versions, or usage patterns -- that your QA team may never exercise. Production monitoring fills this gap. The strategy for production leak monitoring: 1. **Track PSS (Proportional Set Size) over time**: Log your app's PSS at regular intervals (every 60 seconds) and upload summaries to your analytics backend. A monotonically increasing PSS trend over a session indicates a leak. 2. **Monitor OOM crash rates by screen**: Use your crash reporter's breadcrumb feature to identify which screen transitions correlate with OOM crashes. This pinpoints leak sources without heap dumps. 3. **Use LeakCanary in release builds selectively**: LeakCanary 2.x supports a "release" artifact that you can enable for a small percentage of users (1-5%) via feature flags. It reports leak traces to your backend without the notification UI. 4. **Set memory budgets per screen**: Define expected peak memory for each screen based on profiler baselines. Alert when production memory exceeds 150% of the budget. Companies like Uber and Airbnb report that production leak monitoring catches 40-60% more leaks than QA testing alone, because real users exercise code paths that test scripts miss.
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1OOM crashes account for 22% of stability issues in top Play Store apps, with a median 4.7-minute delay from leak to crash.
  2. 2LeakCanary requires a single debugImplementation line and automatically watches Activities, Fragments, ViewModels, Views, and Services.
  3. 3Read leak traces top-down: GC root at top, leaked object at bottom, suspicious references underlined.
  4. 4The four most common leak patterns: ViewModel holding Views, anonymous inner classes, singleton listeners, and Compose remember captures.
  5. 5Use Android Studio Profiler's heap dump to find duplicate objects that should have been garbage collected.
  6. 6Production monitoring with PSS tracking and selective release LeakCanary catches 40-60% more leaks than QA alone.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

How do I know if I have a memory leak?

Signs: increasing memory usage over time, OOM crashes, app slowdown after extended use. Use LeakCanary in development. Monitor memory metrics in production.

What's the most common memory leak?

Static references to Activity or Context. Use application context for long-lived objects. Use WeakReference if you must hold Activity references.

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