weak, unowned, and the crash I shipped
The advice you find everywhere is: use unowned when the reference will never be nil, weak
otherwise. That is technically correct and it is not enough to work with, because “will never be nil”
is a claim about the future.
I shipped a crash on that claim.
The code
A view controller owning a coordinator, with the coordinator calling back:
final class Coordinator {
unowned let viewController: DetailViewController
init(viewController: DetailViewController) {
self.viewController = viewController
}
func finish() {
viewController.dismiss(animated: true)
}
}
unowned because the coordinator is owned by the view controller, so the view controller obviously
outlives it. That reasoning is sound and the crash reports disagreed with it about once a week.
What was actually happening
The coordinator was doing network work. When the user navigated away mid-request, the view controller
was deallocated. The response arrived a second later, the completion handler called finish(), and
unowned did what unowned does: accessed deallocated memory and trapped.
The ownership graph was exactly as I described it. The problem was that the callback outlived both.
viewController → coordinator → (network completion, held by URLSession)
That third arrow is the one I had not drawn. The completion handler kept the coordinator alive past
the view controller’s death, and the coordinator’s unowned reference was now pointing at freed
memory.
Warning
unowned is not “weak without the optional”. It is an assertion that this reference will still be
valid every time it is read, and the penalty for being wrong is a crash — EXC_BAD_ACCESS, in
release builds, on customer devices. weak returning nil is a branch you handle. unowned being
wrong is a bug report.
The rule I use now
Not “will it be nil” but: can I point at the code that guarantees this object is destroyed before the one it points to?
If the answer is a specific line — a deinit, a scope ending, a synchronous call chain — unowned
is safe. If the answer involves the word “should” or “normally”, use weak.
That reframing catches the case above. There is no line of code guaranteeing the coordinator dies
before the view controller, because a closure held by URLSession can keep it alive arbitrarily long.
Where each one is genuinely right
weak for anything crossing an ownership boundary you do not fully control:
- Delegates, always. The delegate outliving the delegator is the normal case.
- Any capture in an escaping closure that might be called late — network completions, timers, notification observers.
- Parent references in a tree.
api.fetch { [weak self] result in
guard let self else { return }
self.handle(result)
}
unowned where the lifetimes are genuinely nested and locally visible:
- A closure stored on an object, capturing that same object, where the closure cannot outlive it.
- Two objects created together and destroyed together, one strictly owned by the other.
final class Loader {
private lazy var onComplete: (Data) -> Void = { [unowned self] data in
self.cache.store(data) // the closure is a property; it dies with self
}
}
That case is safe because the closure is stored on self and cannot be called after self is gone.
There is a line of code guaranteeing it: the property’s own deallocation.
Neither — a strong capture — when the closure genuinely should keep the object alive. This is the case people forget exists:
uploadQueue.enqueue {
self.finishUpload() // strong on purpose: the upload must complete
}
Adding [weak self] here is a bug. It means an upload silently stops if the user navigates away,
which is not what anyone wanted. The question “should this work finish even if nobody is watching?”
has a real answer, and sometimes it is yes.
guard let self and what changed
The old dance was guard let self = self else { return }. Since Swift 5.8, guard let self alone
works, which removed the last reason to write the awkward version.
Worth knowing that the guard makes the reference strong for the rest of the closure. That is
usually what you want — it prevents the object being deallocated halfway through your function,
which would otherwise be possible between two uses of self?.
// self could deallocate between these two lines
self?.startSpinner()
self?.load()
// self is guaranteed alive for both
guard let self else { return }
startSpinner()
load()
The debugging tool
If you suspect an unowned is wrong, the Zombies instrument tells you immediately. It keeps
deallocated objects around as tombstones and reports the exact class and message when one is touched.
Ten minutes with it beats an afternoon of reading ownership graphs.
For finding the cycles that made you reach for weak in the first place, the Memory Graph Debugger
in Xcode — the little graph icon in the debug bar — draws the retain relationships and marks cycles
in purple.
What I actually changed
Every unowned in that codebase, about a dozen, got audited against the new rule. Nine became
weak. Three stayed, all of them closures stored as properties on the object they captured.
Since then: no more EXC_BAD_ACCESS from that class of bug. The cost was some extra guard let self
lines, which is a trade I would take every time.