Closures that hold on
Two people each holding the other’s leash can’t walk away from each other. A retain cycle in iOS is almost always this pair: an object keeps a closure (as a property, or in something it owns), and the closure uses self, so it keeps the object. Neither count ever reaches zero.
Closures you don’t keep are fine: map { $0 * 2 }, withAnimation { … }, a completion handler that runs once and is thrown away. They release self when they finish.
A cycle needs a closed path of strong references: self → (property, cancellables set, observer token, timer, Task handle) → closure → self. Escaping closures stored by something self owns are the risk; non-escaping closures and one-shot escaping closures (a network completion that runs once) only extend the lifetime. [weak self] breaks the edge from closure to self; [unowned self] does too but crashes if the closure outlives self, so reserve it for lifetimes the type system or structure guarantees. Capturing only what’s needed ([service]) avoids capturing self at all.
final class ProductViewController: UIViewController {
private let viewModel: ProductViewModel
private var cancellables = Set<AnyCancellable>()
private var observer: NSObjectProtocol?
override func viewDidLoad() {
super.viewDidLoad()
// 1. A callback stored by an object self owns
viewModel.onChange = { [weak self] state in
self?.render(state)
}
// 2. A Combine subscription stored in self's own set
viewModel.$price
.receive(on: DispatchQueue.main)
.sink { [weak self] price in self?.priceLabel.text = price.formatted() }
.store(in: &cancellables)
// 3. A block-based notification observer
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
self?.viewModel.refresh()
}
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
print("ProductViewController deinit") // proof that it was freed
}
}- In all three, the view controller owns something (the view model, the cancellables, the observer token) that keeps the closure. Without
[weak self], the closure would keep the view controller, closing the loop. self?.calls the method only if the view controller still exists.printindeinitis the simplest leak detector: leave a screen, and the message should appear. If it doesn’t, something still holds it.- For Combine,
.assign(to: \.text, on: priceLabel)is fine, but.assign(to: \.title, on: self)keepsselfstrongly; use.assign(to: &$property)orsinkwith[weak self].
Tap the line with the bug.
final class CartViewController: UIViewController {
private let cart: CartModel
private let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
cart.onItemsChanged = {
self.tableView.reloadData()
self.updateTotal()
}
}
}Hint: Who keeps the closure, and who keeps the cart?
The view controller keeps cart; cart keeps the closure in onItemsChanged; the closure uses self, so it keeps the view controller. Leaving the cart screen removes it from the navigation stack, but the loop keeps all three alive. Every visit adds another set, and each one still reloads its invisible table view whenever the cart changes.
Capture self weakly:
cart.onItemsChanged = { [weak self] in
guard let self else { return }
tableView.reloadData()
updateTotal()
}guard let self makes a strong reference only for the duration of one call, which is fine: the closure doesn’t keep it between calls.
Predict the output
A view controller runs DispatchQueue.main.asyncAfter(deadline: .now() + 10) { self.saveDraft() } and the user closes the screen after 2 seconds. When is the view controller freed?
The closure keeps self alive, but the view controller doesn't keep the closure: the dispatch queue does, and releases it after running it. That's not a cycle, just a delayed release, about 8 seconds later. Whether it's a problem depends on the work: saving a draft after closing is probably wanted; updating labels on a dead screen isn't, so use [weak self] there. A cycle needs a path back from self to the closure.
Delegates, timers and other strong holders
- Delegates: declare
weak var delegate: SomeDelegate?(the protocol must beAnyObject-constrained). A strong delegate is a cycle whenever the delegate owns the delegating object, which is almost always. Timer: a scheduled timer is retained by the run loop and retains its target or block until invalidated.[weak self]in the block isn’t enough to stop it firing; invalidate it inviewWillDisappearor when the owner goes away (and notedeinitwon’t run while the target-based API holds you).CADisplayLinkretains its target the same way; use a weak proxy.URLSessionwith a delegate retains the delegate strongly untilinvalidateAndCancel()orfinishTasksAndInvalidate().- SwiftUI: views are structs and can’t form cycles, but
@ObservableorObservableObjectmodels can: a model that stores a closure capturing itself, or two models referencing each other strongly.
- Cycle = closed path of strong refs; the usual path is self → owned storage (callback,
cancellables, observer token, timer) → escaping closure → self. [weak self]in stored closures;[unowned self]only with guaranteed lifetimes; capture specific dependencies instead of self.- One-shot escaping closures delay release but don’t cycle.
- Weak delegates; invalidate timers and display links;
URLSessiondelegates are retained until invalidation;assign(to:on: self)retains self.
Check this part
Which closure needs [weak self]?
How can you quickly check that a screen is freed?
Why is [weak self] in a repeating Timer block not enough?