ARC and retain cycles — walk through the weak-strong dance in a closure
Retain cycles are the classic ARC interview trap — the weak-strong dance separates candidates who've read about memory management from those who've actually debugged it.
LEARN BY DOINGPut your intuition to work3 steps
ARC and retain cycles — walk through the weak-strong dance in a closure
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#ARC in one paragraph
Automatic Reference Counting is Swift's (and Objective-C's) memory management strategy: the compiler inserts retain and release calls around every strong reference to a class instance, and the instance is deallocated the moment its retain count drops to zero. It's deterministic — unlike a tracing garbage collector, there's no pause-the-world sweep, deallocation happens the instant the last strong reference disappears — but that determinism has a sharp edge: if two objects hold strong references to each other, neither retain count ever reaches zero, and neither object is ever deallocated. That's a retain cycle, and it's a memory leak for the lifetime of the app process.
#Where closures create cycles
Closures capture the variables they reference, and by default they capture strongly — including self. That's fine in isolation. The cycle forms when the relationship is mutual: an object stores a closure as a property (a completion handler, a callback saved for later), and that closure captures self strongly. Now the object holds the closure strongly, and the closure holds the object strongly — a two-node cycle that ARC's reference counting cannot see or break on its own, because from ARC's perspective both objects genuinely have a live strong reference pointing at them.
#The weak-strong dance
The fix is to declare the capture as [weak self] in the closure's capture list, which makes the closure hold a weak, optional reference to self — one that doesn't keep it alive and automatically becomes nil if self is deallocated elsewhere. But a closure body often does several things across multiple lines, and if self were accessed as self? on every line, it could theoretically deallocate between two of those lines on a concurrent queue, giving you inconsistent partial execution. The "dance" is the second half: immediately re-binding the weak optional to a strong local constant with guard let self else { return } (the Swift 5.7+ shorthand for guard let self = self else { return }). For the remainder of that closure invocation, self is a guaranteed-alive strong reference — but it's scoped to that one invocation, so it doesn't recreate the cycle.
#weak vs. unowned
weak references are always optional and Swift automatically sets them to nil the instant the referenced object deallocates — safe, at the small cost of an extra optional to unwrap. unowned references are non-optional and assume the referenced object will always outlive the reference; accessing an unowned reference after its object has deallocated is undefined behavior — typically an immediate crash, sometimes worse. unowned(unsafe) goes further and skips even the runtime safety check for a small performance win, trading a guaranteed crash for potential memory corruption if you're wrong. Reach for unowned only when you can prove the lifetimes are tied together (a parent that guarantees it outlives a child it hands a closure to); default to weak otherwise.
#Finding cycles you didn't mean to create
The most reliable tool is Xcode's Debug Memory Graph (the icon that looks like three connected circles in the debug bar): trigger the flow that should deallocate a screen, then open the memory graph — any object still alive that should be gone shows up, often with a purple exclamation mark flagging a probable leak, and the graph draws the actual reference chain keeping it alive. Instruments' Leaks and Allocations templates catch the same class of problem over a longer session. A cheap manual technique for suspects: add a print or breakpoint in deinit and confirm it fires when you expect the object to go away — silence is the leak.
#Not just closures
Delegate properties are a classic non-closure retain cycle if declared var delegate: SomeDelegate instead of weak var delegate: SomeDelegate? — the delegating object and its delegate often hold references to each other. Parent-child object graphs have the same shape: if a child stores a strong reference back to its parent (to call back into it) while the parent holds a strong reference down to the child, that's a cycle regardless of whether a closure is involved.
#In code
final class ImageDownloader {var onComplete: ((UIImage?) -> Void)?func download(from url: URL) {URLSession.shared.dataTask(with: url) { data, _, _ inlet image = data.flatMap(UIImage.init)DispatchQueue.main.async {self.onComplete?(image) // captured strongly, implicit self}}.resume()}deinit {print("ImageDownloader deallocated")}}final class ProfileViewController: UIViewController {private let downloader = ImageDownloader()override func viewDidLoad() {super.viewDidLoad()// ❌ Retain cycle: self -> downloader -> onComplete closure -> selfdownloader.onComplete = { image inself.imageView.image = image}}deinit {print("ProfileViewController deallocated") // never printed — leaked}}
final class ProfileViewController: UIViewController {private let downloader = ImageDownloader()override func viewDidLoad() {super.viewDidLoad()// ✅ The weak-strong dancedownloader.onComplete = { [weak self] image inguard let self else { return }self.imageView.image = image}}deinit {print("ProfileViewController deallocated") // now printed correctly}}
#The cycle, visually
🧠 Check your understanding
0/3 · 0/3 answered1. What makes a closure create a retain cycle with the object that owns it?
2. Why re-bind to a strong local self right after a [weak self] capture, instead of using self? on every line?
3. What is the key risk of unowned compared to weak?