Why Android apps leak memory
A memory leak happens when an app allocates an object but never releases it once it’s no longer needed. Over time, these unreleased objects pile up, degrading performance and eventually causing crashes. While leaks can occur on any platform, Android apps are particularly susceptible because of the complexity introduced by activity lifecycles. Patterns like ViewModel and LifecycleObserver help mitigate the problem, but older code or simple oversights can still let leaks through.
Two common leak patterns
Activity holds a reference to a fragment
Consider an activity that maintains a reference to one of its child fragments. For as long as the activity lives, the fragment is kept alive too. If the fragment’s view is destroyed before the activity is, the fragment sits in memory longer than it should—a leak that exists from the fragment’s onDestroy until the activity’s onDestroy.
A long-lived service references a fragment’s view
The reverse scenario is just as problematic. If a long-running service grabs a reference to a fragment’s view, that view stays in memory for the service’s entire lifetime. Because the view itself references its parent activity, the activity leaks as well.
Detecting leaks
Watch for OutOfMemoryError
The most basic signal is an OutOfMemoryError crash. Unless you have a single memory-hungry screen, a crash of this kind almost certainly means a leak exists. The caveat is that the crash report only reveals where memory ran out, not where the leak originated—the culprit could be anywhere in the app. Checking crash breadcrumbs for patterns can help, but it’s rarely enough to pinpoint the problem.
LeakCanary
LeakCanary is a dedicated memory leak detection library for Android. Adding it as a dependency in build.gradle means it runs alongside the app during development. As you navigate, LeakCanary periodically dumps the heap and produces leak traces describing what it finds. This is a significant improvement over relying on crash logs, but it still has a limitation: each developer only sees leaks they encounter locally. The process remains manual.
LeakCanary plus Bugsnag
LeakCanary’s documentation includes a recipe for uploading detected leaks to Bugsnag. That turns memory leaks into trackable warnings, on par with other app errors. Bugsnag’s integrations can even route these issues into project management tools like Jira for better visibility and accountability.
LeakCanary in CI and instrumentation tests
Another avenue for automation is running LeakCanary in CI tests. LeakCanary provides an artifact specifically for UI tests. The run listener waits for a test to finish; if the test passes, it looks for retained objects, triggers a heap dump when needed, and performs analysis. The downside is that heap dumps after each test slow the test suite. With selective testing and sharding, however, the added overhead can be negligible. The payoff is that leaks surface as ordinary build or test failures, complete with the leak trace recorded at the time it occurred.
Running LeakCanary on CI also reveals coding issues before they reach production. One example from our experience: a leak traced to MvRx mocks that weren’t cleaned up properly in a test. A few lines of code fixed it. Some leaks only exist in tests, and engineers may choose to ignore them—but tools like this still act as a linter for code smell and bad patterns, helping developers learn better practices.
To skip leak detection on specific tests, we wrote a simple annotation and overrode LeakCanary’s FailOnLeakRunListener(). Applying the annotation to an individual test or an entire class disables detection there.
Fixing leaks once you find them
The leak trace from LeakCanary is the best diagnostic tool available—it prints a chain of references and explains why an object is considered leaked. LeakCanary already has thorough documentation on reading these traces. In practice, two categories of leaks cover most of what we encountered.
View references
Declaring views as class-level variables in fragments is a common habit: private TextView myTextView; in Java, or private lateinit var myTextView: TextView in Kotlin. These variables hold views beyond the fragment’s view lifecycle unless cleared in onDestroyView. With a lateinit variable, you can’t null it out at all.
A typical leak sequence: you’re on FragmentA, navigate to FragmentB, and FragmentA goes on the back stack. FragmentA isn’t destroyed, but its view is. Any view references tied to that view’s lifecycle stay in memory unnecessarily. These leaks are often small, but views holding images, data, or view binding objects can cause real trouble. The safest practice is to avoid storing views as fields or to clean them up explicitly in onDestroyView.
Android’s view binding documentation makes the same point: the binding field must be cleared to avoid leaks, which adds boilerplate to every fragment. Avoid the !! operator when handling nullable bindings—it throws a KotlinNullPointerException. Instead, we created a ViewBindingHolder (and DataBindingHolder) that fragments implement. It ensures the binding is available when needed, allows code to run only when the binding exists, and automatically cleans up the binding in onDestroyView.
Temporal leaks
Some leaks are short-lived, disappearing on their own after a moment. We ran into one caused by an EditTextView async task that outlasted LeakCanary’s default wait time. The leak was reported even though the memory was freed shortly afterward. To check for this, use Android Studio’s memory profiler: reproduce the leak, delay the heap dump longer than usual, and inspect whether the leak still exists.
Test often, fix early
Memory leaks are best caught early, before bad patterns settle into the codebase. They may not impact your own high-end device, but users on lower-memory phones will notice the difference. Testing early and consistently is the most reliable defense.



