How do retain cycles happen in Swift closures and delegates, and how would you find one in an app that is slowly leaking memory?
A closure or delegate reference that captures its owner strongly, while the owner also holds that closure or delegate strongly, forms a cycle ARC cannot break on its own, and the fix is weak or unowned capture chosen by lifetime, not by habit. Use this IOS answer to show the decision, trade-off, and evidence rather than a memorised definition.
What the interviewer is scoring
- Can the candidate explain why ARC cannot detect or break a reference cycle on its own
- Do they distinguish when weak is correct from when unowned is correct, rather than defaulting to one
- Whether they can name a delegate-specific cycle pattern distinct from a closure-capture cycle
- Can they describe a practical way to find a leak, such as the Memory Graph Debugger, rather than only reciting the rule
- Does the candidate know that a strong self capture in an escaping closure is not automatically wrong, only wrong when the owner also holds the closure
Answer
Short answer
A closure or delegate reference that captures its owner strongly, while the owner also holds that closure or delegate strongly, forms a cycle ARC cannot break on its own, and the fix is weak or unowned capture chosen by lifetime, not by habit.
Why ARC cannot rescue you from this on its own
Automatic Reference Counting deallocates an object once its retain count reaches zero, and it does this by tracking strong references, not by analysing the object graph for cycles. Two objects that hold strong references to each other will each keep the other's count above zero forever, even if nothing outside the pair references either one. ARC has no equivalent of a garbage collector's reachability sweep, so a cycle like this is invisible to it by design, not by oversight. That is the fact that makes this a memory-management skill rather than a one-off bug: the language gives you the tool to avoid the cycle, but never detects one that already exists.
The closure-capture version
A closure captures the variables it references from its enclosing scope, and by default it captures self strongly if the closure body touches any instance property or method. The cycle forms when that closure is also stored as a property on the object, because now the object holds the closure strongly, and the closure holds the object strongly.
final class ImageLoader {
var onComplete: ((UIImage) -> Void)?
func load() {
networkClient.fetch { image in
// Strong capture of self, stored in a property that self also
// holds - the object and the closure now hold each other.
self.onComplete?(image)
self.cacheImage(image)
}
}
}
The fix is a capture list that declares how self should be held inside the closure body:
networkClient.fetch { [weak self] image in
// self is now Optional; nil means the loader was deallocated
// before the network call returned, which is the case you must handle.
guard let self else { return }
self.onComplete?(image)
self.cacheImage(image)
}
weak is correct here because the closure may genuinely outlive self - a view controller can be dismissed while a network call is in flight - and the closure must tolerate self being gone. unowned is the wrong choice for this shape because it does not make the reference optional; if self has already been deallocated when the closure runs, an unowned access traps at runtime rather than failing gracefully. unowned is only appropriate when you can guarantee the referenced object's lifetime strictly exceeds the closure's, such as a child object closing over its parent, where the parent cannot be deallocated while the child that captured it is still alive.
The delegate version, which is a different mechanism
Delegate cycles look similar but come from a different place: they happen because the delegating object stores a strong reference to its delegate, and the delegate object also stores a strong reference back to the delegating object as a way of accessing it later.
protocol DownloadDelegate: AnyObject {
func downloadDidFinish(_ download: Download)
}
final class Download {
var delegate: DownloadDelegate? // should be `weak var delegate`
}
final class ViewController: DownloadDelegate {
let download = Download()
func start() {
download.delegate = self // ViewController -> Download -> ViewController
}
func downloadDidFinish(_ download: Download) { /* ... */ }
}
Here ViewController holds download strongly, and if delegate is also strong, download holds ViewController strongly. The convention of declaring delegate properties as weak var delegate: SomeDelegate? exists precisely to break this, and it is why delegate protocols intended for this pattern must be constrained to AnyObject - only a class reference can be marked weak, since weak references rely on ARC's reference-counting machinery, which value types do not participate in.
Finding the leak once you suspect one
The reliable tool is Xcode's Memory Graph Debugger, which pauses the app and draws the live object graph, highlighting cycles with a purple exclamation mark where an object is kept alive only by a reference cycle rather than by anything reachable from a root. That distinguishes a genuine cycle from an object that is merely long-lived on purpose, which a simple memory-growth graph cannot do on its own. Instruments' Leaks and Allocations tools are the complementary view for confirming that memory grows across repeated navigation rather than settling, which is the symptom that sends you looking for the cycle in the first place.
The trap here is treating weak as the universally safe default and reaching for it everywhere. A closure executed synchronously and not stored anywhere does not need a capture list at all, and sprinkling weak self into every closure adds optional-unwrapping noise for cycles that were never possible in that shape. The actual skill is reasoning about which object could plausibly outlive which, in each specific closure or delegate relationship, and choosing strong, weak, or unowned based on that lifetime relationship rather than by reflex.
A retain cycle exists because two objects hold each other strongly at the same time - break exactly one side of that mutual ownership, chosen by which object's lifetime is actually shorter.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Why does delegate almost always need to be declared weak var even when the protocol is a class-only protocol?
- What is the difference between how a retain cycle presents in the Memory Graph Debugger versus a genuine one-off leak?
- Why is unowned dangerous specifically when the closure can outlive the object it captures?
- How does a capture list interact with a closure that is stored as a property versus one that is passed and executed immediately?
Related questions
- How do you choose which devices to test on, and what will an emulator never tell you?mediumAlso on ios5 min
- Your SwiftUI screen redraws constantly and scrolling stutters - how would you find out why, and what state-management mistake usually causes it?mediumAlso on ios4 min
- Why do low-latency systems preallocate arenas instead of calling the general-purpose allocator on the hot path?hardAlso on memory-management7 min
- How do you size the stack and the heap on a device with a fixed RAM budget?mediumAlso on memory-management5 min