I found this retain cycle after a screen that looked completely gone kept receiving updates from a slow network callback. The view controller disappeared, but its memory stayed around until the request finished. Instruments showed the leak; the difficult part was understanding why a small closure had kept an entire screen alive.
The Screen That Would Not Die
The screen displayed a list of account records. Its view model started an asynchronous request and reported progress through a closure. The first implementation captured the view controller strongly:
final class AccountViewController: UIViewController {
private let loader = AccountLoader()
override func viewDidLoad() {
super.viewDidLoad()
loader.load { result in
self.render(result)
}
}
private func render(_ result: Result<[Account], Error>) {
// update the table view
}
}
The loader retained the completion closure until the request completed. The closure retained self, and the controller retained the loader. That formed a cycle: controller → loader → closure → controller. Popping the controller removed it from the navigation stack, but did not release it.
How I Confirmed the Cycle
I first added a deinitializer log, but the message never appeared. Then I used the Memory Graph debugger after presenting and dismissing the screen three times. The graph showed the callback as the retaining edge. Instruments confirmed that the live controller count increased after each navigation loop and dropped only when the request completed.
That detail mattered. The bug was not an infinite leak; it was a lifetime extension caused by an asynchronous operation. A short request made it look harmless, while a slow or cancelled request made the problem obvious.
The First Fix: Weak Capture
The smallest safe change was a weak capture list:
loader.load { [weak self] result in
guard let self else { return }
self.render(result)
}
Now the closure no longer owns the controller. If the user leaves the screen before the callback arrives, the closure exits. This fixed the retain cycle, but it exposed a second issue: the loader still performed work and delivered a result that nobody needed.
Cancellation Was Part of the Fix
Breaking the cycle is not the same as stopping the operation. I changed the loader to retain a cancellable task and cancel it when the controller disappeared:
final class AccountViewController: UIViewController {
private let loader = AccountLoader()
private var loadTask: Task<Void, Never>?
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadTask = Task { [weak self] in
guard let self else { return }
let result = await loader.fetch()
guard !Task.isCancelled else { return }
await MainActor.run { self.render(result) }
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadTask?.cancel()
loadTask = nil
}
}
The weak capture protects the controller, while cancellation prevents stale work from updating a later screen. I also check cancellation immediately before touching the UI because cancellation is cooperative; the request may finish at any point.
A Mistake I Made With Task
My first Swift concurrency version used Task { self.render(...) } and assumed the weak reference was enough. It was not. A task can keep values alive for the duration of its body, and an actor hop can delay the UI update. The final code keeps the weak capture at task creation, unwraps only for the operation it needs, and checks cancellation before rendering.
Testing the Lifetime, Not Just the Result
I added a test that presents the controller, starts a delayed response, dismisses it, and then releases the navigation controller. The test waits for the weak reference to become nil. A second test verifies that a cancelled request does not call the render method after a new controller has been presented.
weak var releasedController: AccountViewController?
autoreleasepool {
let controller = AccountViewController()
releasedController = controller
controller.startForTesting()
}
waitUntil { releasedController == nil }
This kind of test catches the real regression. A successful network response alone does not prove that the screen has a correct lifetime.
What I Use in Code Review Now
- Does an async closure capture a view controller, view model, or service strongly?
- Who owns the task or request, and when is it cancelled?
- Can a late callback update a screen that is no longer current?
- Is cancellation checked after every suspension point that matters?
- Does a deinitializer or weak-reference test prove the expected lifetime?
The practical lesson was not “always use weak self.” Weak capture prevents one ownership edge, but the design still needs cancellation, stale-result handling, and a test for deallocation. Once those three pieces were in place, the controller disappeared immediately after dismissal and the old request stopped competing with the next screen.
Complete runnable project: swift-retain-cycle-async-callback-demo on GitHub
