Home

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.