Android Memory Leaks — The Five Patterns That Cause 90% of Them, and How to Fix Each
Three years ago I inherited a music streaming app where memory usage climbed steadily during a listening session — 180MB after launch, 420MB after an hour, OOM crash after two. The team had been blaming “Android being weird.” It took me a day with LeakCanary to find the actual cause: an equalizer fragment was registering an audio session callback in onResume and never unregistering. Every time the user backed out and came back, another fragment leaked, each holding ~3MB of UI state and equalizer buffers. Forty back-presses in, you’ve got an OOM.
Memory leaks in Android aren’t mysterious. They’re almost always the same five or six patterns, and once you can spot them in code review, you’ll catch them before they ship. This post: how the GC actually works on Android, the patterns that leak, the tools that find them, and the fixes.
What Counts as a Leak (And What Doesn’t)
A memory leak in Android isn’t “memory my app is using.” It’s an object that’s no longer needed but can’t be garbage-collected because something is still holding a reference to it. The GC reaches every object reachable from a set of GC roots — static fields, live threads, JNI references, the main thread’s stack. If your destroyed Activity has a chain of references back to one of those roots, it stays in memory forever.
The classic symptom: rotate the screen 50 times, watch the heap grow by ~2MB each rotation. The Activity is destroyed, but something keeps the reference alive. Multiply that by every screen, every config change, every fragment swap, and your app dies.
What’s not a leak: a Bitmap cache that grows to its configured limit. A connection pool of 5 kept-alive sockets. Memory used by Compose for snapshots. These look like “memory growing” but they’re bounded and intentional.
The Five Patterns That Cause 90% of Leaks
Pattern 1: Long-Lived Listener Registered, Never Unregistered
The equalizer story above. You register a callback with a long-lived object (system service, singleton, repository), and forget to unregister when the short-lived owner dies.
// ❌ Leaks the Fragment every time it’s created
class EqualizerFragment : Fragment() {
override fun onResume() {
super.onResume()
AudioSessionManager.registerListener(this) { profile ->
// AudioSessionManager is a SINGLETON — lives as long as the process
// The lambda captures `this` (the Fragment)
updateUI(profile)
}
}
// No onPause? No unregister? 💥
}
// ✅ Symmetric registration
class EqualizerFragment : Fragment() {
override fun onResume() {
super.onResume()
AudioSessionManager.registerListener(this, ::updateUI)
}
override fun onPause() {
super.onPause()
AudioSessionManager.unregisterListener(this)
// Symmetric: every register has a matching unregister
}
}
The general rule: any time you register something with an object that outlives you, you must unregister. Lifecycle-aware components (anything implementing DefaultLifecycleObserver or using repeatOnLifecycle) handle this for you, which is why modern APIs like Flow.collect with repeatOnLifecycle don’t leak the way old BroadcastReceiver registration did.
Pattern 2: Anonymous Inner Class Holding the Outer Class
An anonymous inner class (or non-capturing lambda compiled to one) implicitly holds a reference to its enclosing class. If that anonymous instance outlives the outer class, the outer is leaked.
// ❌ The Handler keeps a reference to the Activity through this Runnable
class NowPlayingActivity : AppCompatActivity() {
private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handler.postDelayed({
// This lambda captures `this` (NowPlayingActivity)
updateProgressBar()
}, 60_000)
// 60 seconds later, even if the user navigated away,
// the Activity is still in memory
}
}
// ✅ Cancel pending callbacks in onDestroy
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
// Or better: don’t use Handler.postDelayed at all in 2026 —
// use lifecycleScope.launch { delay(60_000); updateProgressBar() }
// which is automatically cancelled when the lifecycle dies
}
This is why lifecycleScope and viewModelScope exist — structured concurrency means the work cancels when the scope dies, so leaks become impossible by construction.
Pattern 3: Static Field Holding a Context
The most insidious. Looks innocent, leaks forever.
// ❌ Static reference to Activity context = permanent leak
object PlaylistCache {
var currentContext: Context? = null
// If anything assigns an Activity context here, that Activity
// is held in memory until the process dies
}
class PlaylistActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
PlaylistCache.currentContext = this
// 💥 Activity now leaked for the lifetime of the process
}
}
The fix is rarely “clear it in onDestroy” (you’ll forget). The fix is: don’t hold context in singletons. If a singleton genuinely needs a context, take Context.applicationContext — it lives as long as the process anyway, so there’s nothing to leak.
// ✅ Application context only
@Singleton
class PlaylistCache @Inject constructor(
@ApplicationContext private val context: Context
// Hilt qualifier — guarantees you get Application context
// Even if someone tries to inject Activity context, this fails at compile time
) { ... }
Pattern 4: View References in ViewModel
ViewModels survive configuration changes. Views don’t. If a ViewModel holds a View reference, you’re leaking the entire view tree on every rotation.
// ❌ Don’t do this
class PlayerViewModel : ViewModel() {
private var progressBar: ProgressBar? = null
fun bind(view: ProgressBar) {
progressBar = view // 💥
}
}
This sounds obvious but it sneaks in via “helper” classes — an animation utility, a snackbar manager, a navigation helper — that’s instantiated in the ViewModel and takes a View parameter somewhere. The rule: ViewModels know about state, not views. State flows out via StateFlow, events via a one-shot channel. The view observes. Never the reverse.
Pattern 5: Coroutines Launched in the Wrong Scope
This one’s subtle because it looks like idiomatic coroutine code.
// ❌ GlobalScope means “live forever”
class TrackRepository {
fun preloadAlbumArt(albumId: String) {
GlobalScope.launch {
// GlobalScope is NOT tied to any lifecycle
// If this coroutine captures anything (it usually does), it leaks
val bitmap = imageLoader.load(albumId)
cache.put(albumId, bitmap)
}
}
}
The fix is rarely “use a different scope” in this exact spot — it’s usually that the function shouldn’t be fire-and-forget at all. Either it’s a suspend function whose caller passes a scope, or it’s scheduled work that belongs in WorkManager. GlobalScope is a yellow flag in code review — legitimate uses exist (true app-lifetime work) but they’re rare.
LeakCanary — Your First Line of Defense
If you’re not running LeakCanary in debug builds, fix that today.
// build.gradle.kts (app module)
dependencies {
debugImplementation(“com.squareup.leakcanary:leakcanary-android:2.14”)
// Only debug — never include in release builds
}
That’s the entire setup. No code. LeakCanary watches every Activity, Fragment, ViewModel, and View root that gets destroyed. If one isn’t garbage-collected within a few seconds, it dumps the heap, traces the reference chain back to a GC root, and shows you a notification with a tree of which references are holding the object alive.
The trace looks like this in practice:
┬───
│ GC Root: System class
│
├─ android.media.AudioManager class
│ Leaking: NO (AudioManager is a system service)
│ ↓ static AudioManager.sListeners
├─ java.util.HashSet
│ Leaking: UNKNOWN
│ ↓ HashSet.elements
│ ~~~~~~~~
├─ com.example.music.EqualizerFragment$onResume$1
│ Leaking: UNKNOWN
│ Anonymous class implementing AudioCallback
│ ↓ EqualizerFragment$onResume$1.this$0
│ ~~~~~~
╰→ com.example.music.EqualizerFragment
Leaking: YES (Fragment.mDestroyed is true)
You read this top to bottom: GC root holds → system HashSet holds → the lambda holds → the Fragment. The fix is wherever the leak chain crosses from “long-lived” to “should-be-short-lived” — in this case, the listener registration. Unregister the listener and the chain breaks.
What LeakCanary doesn’t catch: native memory leaks (Bitmaps before API 26, ByteBuffers, JNI allocations), gradual heap growth from caches that never evict, and slow leaks where the object eventually does get GC’d but later than expected. For those you need the Profiler.
Android Studio Profiler — The Bigger Picture
When LeakCanary says “no leaks” but memory still climbs, switch to the Memory Profiler. The workflow that finds 95% of issues:
- Profile with the app warmed up (do whatever the user would do for 30 seconds)
- Force GC using the trash-can icon — this clears anything actually unreachable
- Take a heap dump
- Repeat the suspect user flow (e.g., open and close the equalizer 10 times)
- Force GC again, take another heap dump
- Compare: any class whose instance count grew by N (where N matches how many times you repeated the flow) is suspect
This is the diff-based approach, and it’s vastly better than staring at a single heap dump. If EqualizerFragment has 11 instances after you opened it 11 times, that’s a leak. If it stays at 1, you’re fine.
The Profiler also shows you allocations over time. A leak looks like a sawtooth that doesn’t fully recover after each GC — the trough rises slowly. A non-leak (just churn) drops back to baseline after every GC.
Compose-Specific Leak Patterns
Compose introduced new ways to leak. Three to know:
Capturing a non-stable lambda in remember. If the lambda captures something Compose can’t prove is stable, the lambda gets recreated every recomposition. Not a leak per se, but it can cause coroutines launched in LaunchedEffect to leak via key invalidation if you’re not careful.
Holding a Context reference inside remember.
// ❌ The MediaPlayer holds the Activity context for the lifetime of the composition
@Composable
fun TrackPreview(trackUrl: String) {
val context = LocalContext.current
val player = remember {
MediaPlayer.create(context, Uri.parse(trackUrl))
// remember without a key means this never disposes
}
// No DisposableEffect to release()? 💥
}
// ✅ DisposableEffect releases native resources
@Composable
fun TrackPreview(trackUrl: String) {
val context = LocalContext.current
val player = remember(trackUrl) {
MediaPlayer.create(context, Uri.parse(trackUrl))
}
DisposableEffect(player) {
onDispose {
player.release()
// MediaPlayer holds native audio buffers — releasing the
// Java reference alone doesn’t free the native memory
}
}
}
Forgetting that rememberCoroutineScope survives recomposition. A coroutine launched in that scope keeps running until the composable leaves composition — and any captured state goes with it. Usually fine, occasionally a problem if the captured state is heavy.
Bitmaps — The Special Case
Bitmaps deserve their own section because they’re the single biggest source of OOM crashes in Android, and the leak math is different.
A 4032×3024 photo (12MP camera output) at ARGB_8888 is 4032 × 3024 × 4 bytes = ~48MB. Decode three of those into memory and you’re past the per-app heap limit on many devices. Even if there’s no leak in the GC sense, you’ve allocated more than the OS allows.
Practical rules:
- Never decode a Bitmap larger than the view that displays it. Use
BitmapFactory.Options.inSampleSizeor, better, use Coil/Glide which size automatically. - Use
RGB_565instead ofARGB_8888for opaque images that don’t need an alpha channel — halves the memory. - Recycle Bitmaps you decoded yourself (pre-API 26 only; on 26+ they’re GC’d like normal objects).
- Don’t cache full-resolution Bitmaps in memory. Cache file paths or thumbnails.
Image loading libraries solve all of this for you. If you’re calling BitmapFactory.decodeFile directly in 2026, you’re reinventing a worse Coil.
Preventing Leaks Before They Happen
Catching leaks is reactive. The patterns that prevent them are:
Use lifecycle-aware APIs by default. repeatOnLifecycle, viewLifecycleOwner, collectAsStateWithLifecycle, lifecycleScope, viewModelScope. The framework handles cancellation and unregistration for you.
Inject Application context, not Activity context, into anything that lives in the DI graph. Hilt’s @ApplicationContext qualifier makes this easy and prevents the “wrong context” mistake at compile time.
Symmetric register/unregister. If you have a register, the next thing you write should be the unregister. In a different lifecycle method, but same PR.
Avoid Handler.postDelayed, Timer, GlobalScope.launch. Use lifecycleScope or viewModelScope with delay. Cancellation is automatic.
Code review checklist for new screens: any listener registered? Any callback passed to a singleton? Any Context stored in a class field? Any direct Bitmap decoding? If yes to any, that’s a check on the diff.
When to Stop Worrying
Two things that look like leaks but aren’t:
Garbage Collector hasn’t run yet. Just because the heap shows your destroyed Activity doesn’t mean it’s leaked — the GC may simply not have collected it yet. Force a GC in the Profiler before declaring a leak.
The OS isn’t pressuring memory. Android only aggressively reclaims memory when something needs it. An app sitting at 200MB in a healthy system isn’t a problem. The same app sitting at 200MB on a low-memory device with a video call active is a problem — you’ll get killed when backgrounded. Test on the worst device your target audience uses, not your flagship.
Closing
The equalizer leak from the opening took me a day to find with LeakCanary, two minutes to fix (add an onPause with the matching unregister), and shipped a hotfix that dropped P99 OOM crashes by 60% over the next week. Memory leaks aren’t mysterious — they’re patterns, the patterns are knowable, and the tools to find them are free and built in.
If you remember nothing else: install LeakCanary today, prefer lifecycle-aware APIs, and never put an Activity context in a singleton. That’s 80% of the prevention right there.
Happy coding!
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.