Illustration for Debugging Memory Leaks with iOS Instruments

Memory management in Swift is handled by ARC (Automatic Reference Counting). It's magical, until it isn't. When it fails, it's usually because we created a reference cycle—two objects holding onto each other so tightly that neither can ever be deallocated.

These "memory leaks" are silent killers. They don't crash your app immediately. Instead, they slowly consume RAM until the OS kills your app for using too much memory, leaving the user with a confusing crash and you with no stack trace.

In this guide, we'll go beyond the basics and look at how to hunt down these leaks using Xcode Instruments.

The Tools of the Trade

1. The Memory Graph Debugger

This is your first line of defense. While running your app in Xcode, click the "Debug Memory Graph" button (the three connected nodes icon).

It pauses your app and shows a snapshot of the heap. Look for the purple exclamation marks. These indicate leaked blocks.

Pro Tip: Click on a leaked object to see the "Backtrace" in the inspector. It often points to the exact line of code where the object was allocated.

2. Instruments: Leaks & Allocations

For deeper analysis, you need Instruments (Product > Profile or Cmd+I).

The Leaks Instrument checks for cycles periodically (usually every 10 seconds).

  1. Hit Record.
  2. Navigate through your app. Push a view controller, then pop it.
  3. Did the "Leaks" track show a red X? If so, the view controller (or something it owns) didn't deallocate.

The Allocations Instrument is for the harder bugs—abandoned memory. This is memory that is still referenced (so technically not a leak) but shouldn't be.

Common Culprits

The Closure Trap

This is responsible for 90% of Swift leaks.

class ViewModel {
    var onDataUpdated: (() -> Void)?
    
    func fetchData() {
        API.get { result in
            // LEAK! 'self' is captured strongly by the closure,
            // and 'self' owns the closure via 'onDataUpdated' (indirectly)
            self.process(result)
        }
    }
}

The Fix: [weak self]

API.get { [weak self] result in
    guard let self = self else { return }
    self.process(result)
}

Delegate Cycles

Delegates should almost always be weak.

protocol MyDelegate: AnyObject { // Must be AnyObject to be weak
    func didSomething()
}

class Child {
    weak var delegate: MyDelegate? // 'weak' breaks the cycle
}

Conclusion

Memory leaks are inevitable in large codebases. The goal isn't to write perfect code on the first try, but to have a workflow to catch them. Make it a habit to check the Memory Graph Debugger before every pull request. Your users' battery life will thank you.