Finding a retain cycle with the Memory Graph Debugger
The symptom was memory climbing steadily as the user navigated: about 4MB per push of a detail screen, never released on pop. Twenty pushes and the app was jettisoned.
I knew it was a retain cycle. Finding which one took five minutes with the right tool and would have taken an afternoon by reading code.
The tool
Run the app, get into the leaking state, then press the Debug Memory Graph button in the debug bar — the icon that looks like three connected nodes.
Xcode pauses the app and builds a graph of every live object and what is keeping it alive. The left sidebar lists instances by class; selecting one draws its retain relationships.
Two settings make this usable, both in Xcode’s preferences under the scheme’s diagnostics:
- Malloc Stack Logging — on, set to “Live Allocations Only”. Without this you get the graph but not the stack trace showing where each object was allocated, and the stack trace is half the value.
- Zombie Objects — off while doing this. It interferes.
The workflow
1. Navigate to the screen, back out, and take the graph.
If the view controller you just dismissed is still in the sidebar, it leaked. That is the whole test, and it takes ten seconds.
2. Filter by your class name.
The sidebar has a filter field at the bottom. Type DetailViewController. If you see more than one
instance after popping — or any instance at all, when you expect zero — you have found the leak.
3. Select it and read the graph.
The graph shows arrows pointing into the selected object. Every arrow is something holding a strong reference. Xcode marks cycles with a purple exclamation badge, which usually points straight at the answer.
Tip
The most useful part of the graph is not the cycle marker — it is the arrow labels. Xcode
annotates each arrow with the property name doing the retaining. Reading “closure captured
variable self” or “viewModel.onUpdate” tells you the exact line to look at.
What mine was
final class DetailViewModel {
var onUpdate: (() -> Void)?
func start() {
service.subscribe { [weak self] value in
self?.value = value
self?.onUpdate?()
}
}
}
final class DetailViewController: UIViewController {
let viewModel = DetailViewModel()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.onUpdate = {
self.render() // ← strong capture of self
}
}
}
The [weak self] in the view model is correct and irrelevant. The cycle is the other one:
DetailViewController → viewModel → onUpdate closure → DetailViewController
I had been careful with the closure that looked dangerous — the one crossing into a service — and completely careless with the one that was three lines from the property it was assigned to.
The fix is one word:
viewModel.onUpdate = { [weak self] in
self?.render()
}
4MB per push became zero.
The check that catches these earlier
Add a deinit that prints, to every view controller and view model, at least while developing:
#if DEBUG
deinit { print("deinit \(type(of: self))") }
#endif
That is crude and it works better than anything else I have tried. Navigate in and out of a screen; if nothing prints, something leaked, and you know within seconds rather than when memory warnings start.
I now treat a missing deinit line the same way I treat a failing test.
The four shapes that cause almost all of them
A closure property capturing its owner. The one above. Anything assigned to a stored closure property on an object that the closure also references.
A delegate declared strong. var delegate: SomeDelegate? instead of weak var delegate: (any SomeDelegate)?. Rare in code you wrote, common in code you inherited.
A Combine sink capturing self, stored in self. self → cancellables → subscription → closure → self. The fix is [weak self] or assign(to: &$published).
A Timer or NotificationCenter observer with a strong target. Timer.scheduledTimer(target: self,…) retains the target, and the timer is retained by the run loop. Nothing is ever released
until you invalidate it — and deinit will never run, so you cannot invalidate it there.
That last one is the nastiest, because the usual fix does not work: you cannot invalidate a timer in
deinit if the timer is what is preventing deinit. It has to be invalidated in
viewWillDisappear or an explicit teardown.
Why the graph beats reading code
Reading code for retain cycles means holding an ownership diagram in your head while checking every closure for capture semantics, and being wrong once is enough to miss it. I did that for an hour before opening the graph, and I had looked directly at the leaking closure without seeing it — because it did not look dangerous.
The graph does not have opinions about which closures look dangerous. It just shows you the arrows.