Memory Leaks in Android: Patterns I've Seen in Production
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)
ViewModelsurvives configuration changes but must not hold references toActivity,Context, orView- 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
| Strategy | Benefit | Cost |
|---|---|---|
Use applicationContext everywhere | Eliminates context-based leaks | Cannot use for UI operations (inflating views, showing dialogs) |
Cancel all coroutines in onDestroy | Prevents lifecycle-scope leaks | Must ensure no work is silently dropped |
| WeakReference for callbacks | Prevents listener leaks | Adds null-checking complexity, can cause silent failures |
| LeakCanary in debug builds | Catches leaks during development | Does not cover all production scenarios |
Failure Modes
| Failure | Symptom | Detection |
|---|---|---|
| Activity leak via static holder | Memory grows on each rotation | LeakCanary, heap dump analysis |
| Fragment leak via retained coroutine | Back stack memory grows linearly | adb shell dumpsys meminfo <package> |
| Bitmap leak in unbounded cache | OutOfMemoryError after scrolling image-heavy screens | Heap histogram showing large byte[] arrays |
| Listener leak via system service | Slow growth over hours of use | Periodic heap dumps in QA builds |
| WebView leak | WebView internals hold Activity reference | Known Android bug, mitigate by hosting WebView in a separate process |
Scaling Considerations
- Automated leak detection in CI: run UI tests with LeakCanary's
DetectLeaksAfterTestSuccessrule. 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
Activityand holds a reference to it causes a leak. This includes singletons, static fields, running coroutines, handler callbacks, and system service listeners - Use
applicationContextfor long-lived objects. UseviewLifecycleOwner.lifecycleScopefor coroutines in fragments - Unregister every listener you register. If the API has
addListener, there must be a correspondingremoveListenerin 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
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
- Memory Allocation Patterns That Hurt Performance: Concrete memory allocation anti-patterns in Android and Kotlin code that degrade performance, with profiling strategies and fixes for each.
- Debugging Performance Issues in Large Android Apps: A systematic approach to identifying, isolating, and fixing performance bottlenecks in large Android codebases, covering profiling strate...
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
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.