Why Is My Android App Crashing? A Complete Guide to Finding and Fixing Memory Leaks
I stared at the crash report in disbelief. My app was crashing with OutOfMemoryError after users kept it open for just 10 minutes. The stack trace showed nothing useful—just a generic memory allocation failure. I had tested every feature thoroughly during development, so what went wrong?
After hours of debugging with Android Studio Profiler, I discovered the culprit: a memory leak in my singleton class that was holding references to destroyed Activities. Every screen rotation was leaving behind a zombie Activity that couldn’t be garbage collected. This is the story of how I learned to hunt down and fix memory leaks in Android apps.
What Exactly Is a Memory Leak?
A memory leak occurs when an object is no longer needed but cannot be garbage collected because something still holds a reference to it. In Android, this is particularly painful because:
- Limited heap space: Android devices allocate limited memory per app (typically 128-512MB depending on device and Android version)
- Frequent lifecycle changes: Activities are destroyed and recreated constantly—every rotation, every background press, every configuration change
- Silent accumulation: Leaks don’t crash immediately; they accumulate until you hit the memory limit
Here’s what happens in a typical leak scenario:
┌─────────────────┐│ Activity A ││ (destroyed) │└────────┬────────┘ │ still referenced by ▼┌─────────────────┐│ Singleton ││ (long-lived) │└─────────────────┘ │ ▼ GC cannot collect Activity A Memory usage keeps growing Eventually → OutOfMemoryErrorThe symptoms are subtle at first:
- App becomes sluggish after extended use
- Memory graph in profiler shows continuous upward trend
- Crashes that only happen after “too many” screen rotations
- Users complaining about battery drain
The Five Most Common Leak Patterns
Pattern 1: The Static Context Trap
I made this mistake in my first production app. I needed a global helper class, so I stored an Activity Context in a static variable:
class ImageLoader private constructor() { private var context: Context? = null
companion object { private var instance: ImageLoader? = null
fun getInstance(context: Context): ImageLoader { if (instance == null) { instance = ImageLoader() instance!!.context = context // LEAK! Activity Context trapped here } return instance!! } }
fun loadImage(url: String, imageView: ImageView) { // Use context to load images }}Every time I called ImageLoader.getInstance(this) from an Activity, I trapped that Activity’s Context in a static singleton. The singleton lives for the entire process lifetime, so the Activity can never be garbage collected—even after it’s destroyed.
The fix: Use Application Context for singletons:
class ImageLoader private constructor(private val appContext: Context) {
companion object { @Volatile private var instance: ImageLoader? = null
fun getInstance(context: Context): ImageLoader { return instance ?: synchronized(this) { instance ?: ImageLoader(context.applicationContext).also { instance = it } } } }
fun loadImage(url: String, imageView: ImageView) { // Use appContext which lives for entire process }}The rule is simple: Use Application Context for anything that outlives an Activity (singletons, static fields, background services). Use Activity Context only for UI operations like inflating layouts or showing dialogs.
Pattern 2: The Inner Class Snare
My second leak was sneakier. I used an AsyncTask to fetch data, but I didn’t realize non-static inner classes hold implicit references to their outer class:
class UserProfileActivity : AppCompatActivity() { private lateinit var userAvatar: ImageView
private inner class FetchAvatarTask : AsyncTask<String, Void, Bitmap>() { override fun doInBackground(vararg urls: String): Bitmap { // This task runs for 5 seconds // But if user rotates screen during this time... Thread.sleep(5000) return downloadImage(urls[0]) }
override fun onPostExecute(result: Bitmap) { // Implicit reference to UserProfileActivity here! userAvatar.setImageBitmap(result) // LEAK if Activity destroyed } }
fun loadUserAvatar(url: String) { FetchAvatarTask().execute(url) }}When the user rotates the screen, the Activity is destroyed and recreated. But the AsyncTask keeps running with its implicit reference to the old Activity. That Activity is now leaked until the task finishes.
I tried fixing it with WeakReference:
class UserProfileActivity : AppCompatActivity() { private lateinit var userAvatar: ImageView
private class FetchAvatarTask(activity: UserProfileActivity) : AsyncTask<String, Void, Bitmap>() { private val activityRef: WeakReference<UserProfileActivity> = WeakReference(activity)
override fun doInBackground(vararg urls: String): Bitmap { Thread.sleep(5000) return downloadImage(urls[0]) }
override fun onPostExecute(result: Bitmap) { activityRef.get()?.userAvatar?.setImageBitmap(result) } }
fun loadUserAvatar(url: String) { FetchAvatarTask(this).execute(url) }}This works, but it’s verbose and error-prone. The better solution is to use lifecycle-aware coroutines:
class UserProfileActivity : AppCompatActivity() { private lateinit var userAvatar: ImageView
fun loadUserAvatar(url: String) { lifecycleScope.launch { val bitmap = withContext(Dispatchers.IO) { // Runs on background thread downloadImage(url) } // Automatically cancelled if Activity is destroyed userAvatar.setImageBitmap(bitmap) } }}With lifecycleScope, the coroutine is automatically cancelled when the lifecycle owner is destroyed. No manual cleanup needed.
Pattern 3: Handler and Runnable Nightmares
I used Handler to post delayed messages, but I forgot that Runnables posted to a Handler retain their outer class:
class CountdownActivity : AppCompatActivity() { private lateinit var countdownText: TextView private val handler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
handler.postDelayed({ // This Runnable holds reference to Activity! countdownText.text = "Time's up!" }, 30000) // 30 seconds delay } // If user closes Activity before 30s → LEAK}The lambda passed to postDelayed captures countdownText, which captures the Activity. If the user closes the Activity before the 30-second delay, the Activity is leaked.
My first attempt at fixing this was manual cleanup:
class CountdownActivity : AppCompatActivity() { private lateinit var countdownText: TextView private val handler = Handler(Looper.getMainLooper()) private val countdownRunnable = Runnable { countdownText.text = "Time's up!" }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handler.postDelayed(countdownRunnable, 30000) }
override fun onDestroy() { super.onDestroy() handler.removeCallbacks(countdownRunnable) // Must remember this! }}But this is fragile. I once forgot to call removeCallbacks in a Fragment and spent hours debugging the leak. The modern approach is again coroutines:
class CountdownActivity : AppCompatActivity() { private lateinit var countdownText: TextView
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
lifecycleScope.launch { delay(30000) countdownText.text = "Time's up!" } // Automatically cancelled in onDestroy, no cleanup needed }}Pattern 4: Forgotten Listener Registrations
This one bit me during a code review. I registered a BroadcastReceiver but forgot to unregister it:
class NetworkMonitorActivity : AppCompatActivity() { private val networkReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { updateNetworkStatus() } }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) registerReceiver(networkReceiver, filter) // LEAK: Never unregistered! }}Every time this Activity was created, a new receiver was registered. The system holds onto these receivers, and each one keeps its Activity alive.
The fix requires careful lifecycle management:
class NetworkMonitorActivity : AppCompatActivity() { private val networkReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { updateNetworkStatus() } }
override fun onStart() { super.onStart() val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) registerReceiver(networkReceiver, filter) }
override fun onStop() { super.onStop() unregisterReceiver(networkReceiver) }}The key question: Should I register/unregister in onStart/onStop or onCreate/onDestroy? For receivers that affect UI, use onStart/onStop so the receiver only runs when the Activity is visible. For background operations, use onCreate/onDestroy.
Even better, use the modern Context.registerReceiver API with lifecycle awareness:
class NetworkMonitorActivity : AppCompatActivity() { override fun onStart() { super.onStart()
val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { updateNetworkStatus() } }
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION) registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
// Automatically unregistered when lifecycle ends lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onDestroy(owner: LifecycleOwner) { unregisterReceiver(receiver) } }) }}Pattern 5: ViewModel Holding View References
This was my most embarrassing leak. I thought ViewModels were supposed to survive configuration changes, so I stored View references in them:
class UserViewModel : ViewModel() { private var userTextView: TextView? = null // NEVER DO THIS!
fun bindTextView(textView: TextView) { userTextView = textView }
fun updateUserName(name: String) { userTextView?.text = name }}This completely defeats the purpose of ViewModels. ViewModels survive configuration changes, but Views do not. Storing a View reference in a ViewModel guarantees a leak on every rotation.
The correct pattern is to expose data, not Views:
class UserViewModel : ViewModel() { private val _userName = MutableStateFlow("") val userName: StateFlow<String> = _userName.asStateFlow()
fun updateUserName(name: String) { _userName.value = name }}
// In Activityclass UserActivity : AppCompatActivity() { private val viewModel: UserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
lifecycleScope.launch { viewModel.userName.collect { name -> userNameTextView.text = name } } }}The ViewModel holds only data. The Activity observes the data and updates the View. When the Activity is destroyed, the observation stops automatically.
Detecting Leaks with LeakCanary
After my crash incident, I added LeakCanary to every debug build. It’s the single most valuable tool for catching leaks early:
dependencies { // Add to app-level build.gradle debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'}That’s it. LeakCanary automatically installs itself in debug builds. When a leak is detected, you get a notification showing the complete reference chain:
┌───│ GC Root: System class│├─ com.android.internal.util.GrowingArrayUtils class│ Leaking: NO (MySingleton↓ is not leaking)│ ↓ static MySingleton.instance├─ com.example.MySingleton instance│ Leaking: UNKNOWN│ ↓ MySingleton.context├─ com.example.MainActivity instance│ Leaking: YES (Activity has been destroyed)│ ↓ MainActivity.mDestroyed│ ~~~~~~~~~~~╰→ java.lang.ref.WeakReference instance Leaking: YES (MainActivity↓ is leaking) ↓ WeakReference.referentThis trace tells me exactly where the leak is: MySingleton.context holds a reference to a destroyed MainActivity.
Manual Detection with Android Studio Profiler
Sometimes LeakCanary misses leaks (especially in production builds), so I also use Android Studio Profiler:
- Run app with profiling:
Run > Profile 'app' with Profiler - Navigate through the app normally
- Trigger configuration changes (rotate, background/foreground)
- Click “Capture heap dump”
- Filter by Activity class name
- Look for instances of destroyed Activities
Step 1: Open app, navigate to target screenStep 2: Force GC (trash can icon)Step 3: Record heap dumpStep 4: Filter: class name contains "Activity"Step 5: Look for: Activity instances with mDestroyed = trueStep 6: Click instance → see "References" panelStep 7: Find the reference chain preventing GCThe profiler shows me exactly which objects are keeping the Activity alive.
The Checklist I Use Before Every Release
After learning these lessons the hard way, I now follow this checklist:
- LeakCanary added to debug builds
- Run through all user flows with screen rotations
- Check profiler for growing memory usage
- Review all singletons for Context usage
- Verify all BroadcastReceivers are unregistered
- Confirm all Handlers clean up in onDestroy
- Ensure ViewModels expose data, not Views
- Use lifecycleScope for coroutines, not GlobalScope
Why This Knowledge Matters for Your Career
When I interviewed for a senior Android position, the interviewer asked: “Tell me about a memory leak you’ve encountered and how you fixed it.”
This question separates developers who’ve just followed tutorials from those who’ve built and maintained production apps. Understanding memory leaks demonstrates:
- Deep platform knowledge: You understand Android lifecycle, not just API calls
- Debugging skills: You can diagnose problems, not just copy solutions
- Architectural thinking: You design with memory management in mind
- Production experience: You’ve seen apps fail in real user scenarios
Common Mistakes I Still See
Even experienced developers make these mistakes:
Mistake 1: Using Activity Context everywhere
// WRONG: Activity Context for long-lived objectsval prefs = context.getSharedPreferences("name", Context.MODE_PRIVATE)
// RIGHT: Application Contextval prefs = context.applicationContext.getSharedPreferences("name", Context.MODE_PRIVATE)Mistake 2: Ignoring lifecycle in coroutines
// WRONG: GlobalScope survives Activity destructionGlobalScope.launch { delay(10000) textView.text = "Done" // Crashes if Activity destroyed}
// RIGHT: lifecycleScope is cancelled on destroylifecycleScope.launch { delay(10000) textView.text = "Done" // Safe}Mistake 3: Not testing edge cases
- Rotate during network request
- Background app during long operation
- Quickly navigate between screens
Final Thoughts
Memory leaks are silent killers of Android apps. They don’t crash immediately, but they accumulate until your app becomes unusable. The key principles to remember:
- Understand lifecycle: Know when objects are created and destroyed
- Use the right Context: Application Context for long-lived objects
- Clean up references: Unregister, remove callbacks, cancel jobs
- Leverage lifecycle-aware components: Coroutines, ViewModel, LiveData, StateFlow
- Detect early: LeakCanary in every debug build
Start by adding LeakCanary to your current project. Run through your app’s main flows, rotate the screen frequently, and fix every leak it reports. The reference chains it shows you will teach you more about Android memory management than any tutorial.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments