AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Mobile Memory Leak Debugging: Retain Cycles & Leaks

NFNourin Mahfuj Finick··9 min read

Every mobile app that runs long enough will eventually leak memory if its developers aren't careful. A mobile memory leak is a block of heap memory your app no longer needs but can no longer reclaim, because a reference to it never goes away. Unlike a crash, a leak doesn't fail loudly: it grows gradually, one retained object at a time, until the system's memory pressure forces the outcome you were trying to avoid. This guide walks through native memory leak debugging on both platforms — iOS retain cycles under Automatic Reference Counting (ARC) and Android leaks surfaced by LeakCanary and heap profiling — so you can find the leak before it becomes an out-of-memory kill.

Why Mobile Memory Leaks Matter

On iOS, the system's Jetsam process terminates apps that exceed their memory budget; on Android, the low memory killer (LMK) does the same using oom_score_adj. Leaks are the slow fuse for both. A single leaked Activity on Android can hold onto its entire view hierarchy, and a single retain cycle in a View Controller can pin a screen's worth of textures in memory forever. Users experience the symptoms long before a kill happens: dropped frames, sluggish scrolling, and increasing memory usage that never returns to baseline. If you're already fighting memory kills, this post pairs with our guide to OOM crash debugging, which covers the kill itself rather than the leak that often causes it.

iOS Memory Leaks: ARC and Retain Cycles

Swift and Objective-C use Automatic Reference Counting (ARC) to manage memory. Every strong reference to an object increments its retain count; when the count reaches zero, the object is deallocated. ARC eliminates most manual memory bugs, but it cannot break a cycle: if object A strongly references object B, and B strongly references A, both retain counts stay above zero forever, and neither is ever freed. This is a retain cycle, and it is the single most common source of native iOS leaks.

Closures That Capture self

The most frequent retain cycle in iOS code is a closure that captures self strongly while self owns the closure. A network callback stored as a property is the classic case:

class ImageLoader {
    var onComplete: (() -> Void)?
 
    func load() {
        onComplete = {
            // 'self' is captured strongly, and 'self' owns 'onComplete'
            self.render()
        }
    }
}

Here the closure holds self strongly, and self holds the closure through the onComplete property — a cycle that ARC never breaks. The standard fixes are a capture list with [weak self] or [unowned self]:

onComplete = { [weak self] in
    self?.render()
}

weak produces an optional reference that becomes nil when the object is deallocated; unowned produces a non-optional reference that assumes the object outlives the closure and will trap if that assumption is wrong. Reach for weak when the lifetime of the captured object is genuinely uncertain, and reserve unowned for relationships where the captured object is guaranteed to outlive the closure.

Delegates and Notification Observers

Delegate properties are another silent cycle. A delegate should almost always be weak — if a view controller is the delegate of a child object that the view controller also owns, a strong delegate property creates a cycle that keeps both alive:

protocol LoaderDelegate: AnyObject { func didLoad() }
 
class Loader {
    weak var delegate: LoaderDelegate?   // weak, not strong
}

NotificationCenter's block-based observer API has a subtler trap: when you register with a block, the notification center retains the block, and the block captures self. Even with a [weak self] capture, the block itself remains registered until you remove the token, so the observation — not the object — keeps firing:

let token = NotificationCenter.default.addObserver(
    forName: UIApplication.didReceiveMemoryWarningNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    self?.purgeCaches()
}
 
deinit {
    NotificationCenter.default.removeObserver(token)
}

Forgetting removeObserver(token) is a leak of the observation machinery and, if you captured self strongly, a leak of the object itself. iOS 9's auto-removing block observers were deprecated because they silently stopped firing; explicit removal in deinit remains the correct pattern.

Timers and Dispatch Sources

Timer and DispatchSourceTimer are easy to overlook because they hold their targets strongly. A repeating timer stored on a view controller that also sets the view controller as its target will keep the controller alive indefinitely:

class Poller {
    var timer: Timer?
 
    func start() {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            self?.tick()
        }
    }
 
    deinit {
        timer?.invalidate()
    }
}

The block-based timer still requires invalidate() in deinit because the run loop retains the timer regardless of how the block captures self.

weak vs unowned, in Practice

A useful mental model: weak is safe and always works, at the cost of a nil check; unowned is a performance micro-optimization that trades safety for convenience. Apple's ARC documentation recommends unowned only when the referenced instance has the same or a longer lifetime. When in doubt, use weak — a crash from an over-released unowned reference is strictly worse than a nil check.

Finding Leaks with the Memory Graph and Instruments

Xcode ships two tools that make retain cycles visible. The Memory Graph Debugger (the "debug memory graph" button in the debug bar) renders every live object and its references, so a cycle shows up as a loop of arrows you can click through. The Instruments Leaks template then confirms it: it samples allocations over time and flags objects that are no longer reachable from the app's root set. Run the Allocations instrument alongside Leaks to watch a suspect screen's live bytes after you navigate away and back — memory that never returns to baseline is a leak in all but name.

Android Memory Leaks: LeakCanary and Heap Profiling

On Android, the garbage collector frees unreachable objects automatically, but an object is only "unreachable" if nothing references it. The most damaging Android leaks are context leaks: a long-lived object holds a reference to an Activity or Fragment, which pins the entire activity — views, bitmaps, and all — in memory.

LeakCanary Setup

LeakCanary by Square is the de-facto tool for finding these leaks in development. Add the debug-only dependency so it never ships to production:

dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}

LeakCanary auto-installs via its ContentProvider in debug builds, watches for destroyed objects that are still referenced, dumps the heap when one is found, and surfaces a notification with a full leak trace. You can also hook it programmatically if you want custom handling:

class ExampleApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        if (LeakCanary.isInAnalyzerProcess(this)) return
        // LeakCanary auto-initializes in debug; nothing required here.
    }
}

Reading a Leak Trace

A LeakCanary trace shows the exact chain of references from a GC root to the leaked object. You'll see lines like a static field holding an application context, which holds a Handler, which holds a message with a Runnable, which holds an Activity. The object at the end of that chain is the leak, and the fix is to break any single link in the chain — usually the strongest one. LeakCanary's documentation explains the reference-chain analysis in detail.

Context, Handler, and Inner-Class Leaks

A non-static inner class holds an implicit reference to its outer class. If the inner class outlives the outer class, the outer class leaks. Handlers are the classic offender because a postDelayed runnable can outlive its activity:

class MyActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handler.postDelayed({ updateUi() }, 60_000)
    }
}

If the user rotates or leaves the screen before the 60 seconds elapse, the pending message holds the Runnable, which holds MyActivity, which leaks for a minute — or longer if the message is reposted. The fixes are a static handler with a WeakReference, or handler.removeCallbacksAndMessages(null) in onDestroy.

Static fields and singletons are the other big one: a static Context or a singleton that caches a view keeps that object alive for the entire process lifetime. Always prefer applicationContext over an activity context for anything that outlives a single screen, and never let a singleton hold a reference to a view, Activity, or Fragment.

Android Studio Memory Profiler and HPROF

For leaks LeakCanary misses — or to measure how much memory each screen really holds — use the Android Studio Memory Profiler. It shows a live heap chart, lets you trigger a GC, and captures a heap dump. You can capture an HPROF file, then open it in Eclipse MAT to run leak-suspect reports and dominance-tree analysis, which tells you which objects are keeping the heap alive. The Android memory overview is the reference for how the runtime allocates and collects memory.

Prevention Best Practices

The cheapest leak to fix is the one you never write. A few habits eliminate most native leaks before a profiler is ever opened. On iOS, default every self capture in an escaping closure to [weak self], make every delegate weak, and remove every NotificationCenter observer, Timer, and KVO observer in deinit. On Android, use applicationContext for long-lived objects, keep Handlers static or remove their callbacks, avoid non-static inner classes that outlive their owner, and prefer ViewModel and lifecycle-aware coroutines so work is scoped to the right lifecycle automatically. Add LeakCanary in debug and run it on every feature branch; add a heap-dump step to your pre-release testing workflow so leaks never reach a release build.

Monitoring Leaks in Production

Development tools catch leaks you reproduce; production monitoring catches leaks you didn't. Watch memory metrics in the field — a rising average of retained heap across sessions, or a growing gap between low and high memory marks, is a leak signal that precedes a crash. Correlate that with session breadcrumbs so you can see which screen or user flow precedes the growth. Bugspulse ties crash reporting, memory trends, and user-session context together in one place, so when a leaked screen finally triggers an OOM kill you can walk backward from the kill to the leak, not just forward from a guess.

Memory leak debugging is a discipline, not a one-time fix. On iOS, it means understanding ARC well enough to see the cycles before they form. On Android, it means letting LeakCanary and heap profiling tell you the truth about what's still alive. Build the habits, wire the tooling into your CI and release checks, and watch production memory trends, and the gradual growth that ends in an out-of-memory kill becomes a problem you find early instead of a crash your users discover first. If you want that visibility in one dashboard, start tracking memory and crashes with Bugspulse.