
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).
- Hit Record.
- Navigate through your app. Push a view controller, then pop it.
- 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.
- Mark Generation: This is the killer feature.
- Go to a neutral state (e.g., Home Screen).
- Click "Mark Generation" (Flag A).
- Push a VC, do some work, pop the VC.
- Click "Mark Generation" (Flag B).
If Flag B shows a significant increase in "Persistent Bytes" compared to Flag A, you have abandoned memory. The objects created between A and B are still alive.
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.