Memory Leaks in Android: Patterns I've Seen in Production

Dhruval Dhameliya·February 3, 2026·8 min read

Real-world memory leak patterns from production Android apps, covering lifecycle-bound leaks, static references, listener registration, and systematic detection strategies.

Context

Memory leaks in Android apps cause gradual heap growth, increased GC pressure, and eventually OutOfMemoryError crashes. Unlike server-side leaks that manifest over hours or days, mobile leaks surface during typical user sessions because the heap is constrained (128MB to 512MB depending on device).

Related: Designing Event Schemas That Survive Product Changes.

See also: Mobile Analytics Pipeline: From App Event to Dashboard.

Problem

A memory leak occurs when an object is no longer needed but cannot be garbage collected because something still holds a reference to it. On Android, the most damaging leaks involve Activity and Fragment instances, because they hold references to their entire view hierarchy, which in turn holds bitmaps, drawables, and layout data.

A single leaked Activity can retain 10 to 50MB of heap. Rotate the device three times, and you have three leaked activities consuming 30 to 150MB.

Constraints

  • Android activities and fragments are destroyed and recreated on configuration changes
  • The garbage collector cannot collect objects reachable from GC roots (static fields, thread stacks, JNI references)
  • ViewModel survives configuration changes but must not hold references to Activity, Context, or View
  • Coroutine scopes tied to the wrong lifecycle will keep references alive past their intended lifetime
  • Third-party SDKs can leak contexts without your knowledge

Design

Pattern 1: Static Reference to Context

The classic leak. A singleton or companion object holds a reference to an Activity context.

// Leaked: static reference to Activity context
object AnalyticsTracker {
    private lateinit var context: Context
 
    fun init(context: Context) {
        this.context = context // If this is an Activity, it leaks
    }
}
 
// Fixed: use Application context
object AnalyticsTracker {
    private lateinit var context: Context
 
    fun init(context: Context) {
        this.context = context.applicationContext
    }
}

Rule: any long-lived object that needs a Context must use applicationContext. No exceptions.

Pattern 2: Inner Class Holding Activity Reference

Non-static inner classes in Java (and inner classes in Kotlin) hold an implicit reference to the outer class. If the inner class outlives the outer class, the outer class leaks.

class DashboardActivity : AppCompatActivity() {
 
    // This Runnable holds an implicit reference to DashboardActivity
    private val delayedTask = Runnable {
        updateUI()
    }
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handler.postDelayed(delayedTask, 30_000) // 30 seconds
    }
 
    // If the Activity is destroyed before 30 seconds,
    // the Handler queue keeps the Runnable alive, which keeps the Activity alive
 
    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacks(delayedTask) // Fix: remove pending callbacks
    }
}

Pattern 3: Unregistered Listeners and Callbacks

Registering a listener on a system service or shared object without unregistering it on teardown.

class LocationActivity : AppCompatActivity() {
    private val locationManager by lazy {
        getSystemService(LOCATION_SERVICE) as LocationManager
    }
 
    private val locationListener = object : LocationListener {
        override fun onLocationChanged(location: Location) {
            updateMap(location) // References the Activity
        }
    }
 
    override fun onResume() {
        super.onResume()
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER, 1000L, 10f, locationListener
        )
    }
 
    // Missing onPause/onDestroy unregistration = leak
    // LocationManager (system service) holds reference to locationListener,
    // which holds reference to Activity
 
    override fun onPause() {
        super.onPause()
        locationManager.removeUpdates(locationListener) // Fix
    }
}

Common offenders: SensorManager, LocationManager, ConnectivityManager.NetworkCallback, BroadcastReceiver, SharedPreferences.OnSharedPreferenceChangeListener.

Pattern 4: ViewModel Holding View or Context

// Leaked: ViewModel outlives Activity but holds its context
class ProfileViewModel(
    private val context: Context // If this is Activity context, it leaks
) : ViewModel() {
    fun loadAvatar() {
        val bitmap = BitmapFactory.decodeResource(context.resources, R.drawable.avatar)
        // ...
    }
}
 
// Fixed: use AndroidViewModel or inject Application context
class ProfileViewModel(
    application: Application
) : AndroidViewModel(application) {
    fun loadAvatar() {
        val bitmap = BitmapFactory.decodeResource(
            getApplication<Application>().resources, R.drawable.avatar
        )
    }
}

Pattern 5: Coroutine Scope Outliving Lifecycle

class SearchFragment : Fragment() {
    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
 
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        scope.launch {
            val results = repository.search(query)
            // This lambda captures `view` and the Fragment
            binding.recyclerView.adapter = ResultsAdapter(results)
        }
    }
 
    // If scope is not cancelled, coroutines keep the Fragment alive
    // after it is removed from the back stack
 
    override fun onDestroyView() {
        super.onDestroyView()
        scope.cancel() // Fix: cancel on view destruction
    }
}
 
// Better: use viewLifecycleOwner.lifecycleScope (auto-cancelled)
class SearchFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        viewLifecycleOwner.lifecycleScope.launch {
            val results = repository.search(query)
            binding.recyclerView.adapter = ResultsAdapter(results)
        }
    }
}

Pattern 6: Bitmap and Drawable Leaks

Large bitmaps held in static caches or by views that are not properly recycled.

// Dangerous: unbounded static cache
object ImageCache {
    private val cache = mutableMapOf<String, Bitmap>() // Grows forever
 
    fun put(key: String, bitmap: Bitmap) {
        cache[key] = bitmap
    }
}
 
// Fixed: use LruCache with a memory budget
object ImageCache {
    private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
    private val cacheSize = maxMemory / 8
 
    val cache = object : LruCache<String, Bitmap>(cacheSize) {
        override fun sizeOf(key: String, bitmap: Bitmap): Int {
            return bitmap.byteCount / 1024
        }
    }
}

Trade-offs

StrategyBenefitCost
Use applicationContext everywhereEliminates context-based leaksCannot use for UI operations (inflating views, showing dialogs)
Cancel all coroutines in onDestroyPrevents lifecycle-scope leaksMust ensure no work is silently dropped
WeakReference for callbacksPrevents listener leaksAdds null-checking complexity, can cause silent failures
LeakCanary in debug buildsCatches leaks during developmentDoes not cover all production scenarios

Failure Modes

FailureSymptomDetection
Activity leak via static holderMemory grows on each rotationLeakCanary, heap dump analysis
Fragment leak via retained coroutineBack stack memory grows linearlyadb shell dumpsys meminfo <package>
Bitmap leak in unbounded cacheOutOfMemoryError after scrolling image-heavy screensHeap histogram showing large byte[] arrays
Listener leak via system serviceSlow growth over hours of usePeriodic heap dumps in QA builds
WebView leakWebView internals hold Activity referenceKnown Android bug, mitigate by hosting WebView in a separate process

Scaling Considerations

  • Automated leak detection in CI: run UI tests with LeakCanary's DetectLeaksAfterTestSuccess rule. Fail the build on any leak
  • Production heap monitoring: track Runtime.getRuntime().totalMemory() and report to your metrics backend. Alert on sustained growth patterns
  • Modular architecture: memory leaks in one feature module should not affect others. Scope dependencies to feature lifecycle using DI scopes (Hilt @ActivityScoped, @FragmentScoped)
  • Process isolation for WebView: host WebView in a separate process via android:process=":webview" in the manifest to isolate its known memory issues

Observability

  • LeakCanary: automatic detection with reference trace in debug builds
  • adb shell dumpsys meminfo: shows heap size, native allocations, view count, activity count (pass the package name as argument)
  • Android Studio Profiler: real-time heap allocations, GC events, and heap dump capture
  • Production telemetry: track activity count (should be 1 for single-activity apps), retained fragment count, and heap utilization percentage
// Lightweight production memory reporter
class MemoryReporter(private val analytics: Analytics) {
    fun report() {
        val runtime = Runtime.getRuntime()
        val usedMemoryMB = (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
        val maxMemoryMB = runtime.maxMemory() / (1024 * 1024)
        val utilization = usedMemoryMB.toFloat() / maxMemoryMB.toFloat()
 
        analytics.track("memory_snapshot", mapOf(
            "used_mb" to usedMemoryMB,
            "max_mb" to maxMemoryMB,
            "utilization" to utilization
        ))
    }
}

Key Takeaways

  • Any object that outlives an Activity and holds a reference to it causes a leak. This includes singletons, static fields, running coroutines, handler callbacks, and system service listeners
  • Use applicationContext for long-lived objects. Use viewLifecycleOwner.lifecycleScope for coroutines in fragments
  • Unregister every listener you register. If the API has addListener, there must be a corresponding removeListener in the teardown path
  • LeakCanary in debug builds plus automated UI tests catches most leaks before they reach production
  • Track heap utilization in production. A steady upward trend across sessions is a leak signal

Further Reading

Final Thoughts

Memory leaks are not exotic bugs. They are the natural consequence of Android's lifecycle model combined with closures, callbacks, and long-lived singletons. Every production app I have worked on has had at least one leak pattern from this list. The discipline is straightforward: scope references to the correct lifecycle, unregister what you register, and automate detection in your test pipeline. The cost of a leaked activity in production is measured in OOM crashes, poor vitals, and users who do not come back.

Recommended