How do you profile memory on iOS — what does Instruments actually show you about a retain cycle?
Two objects holding strong references to each other never hit zero — Instruments makes that invisible leak visible as a graph, not a guess.
LEARN BY DOINGPut your intuition to work3 steps
How do you profile memory on iOS — what does Instruments actually show you about a retain cycle?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why retain cycles happen at all
iOS memory management is ARC (Automatic Reference Counting): every object carries a retain count, incremented by each strong reference to it and decremented when that reference goes away; the object is deallocated the instant the count hits zero. A retain cycle happens when two (or more) objects hold strong references to each other, directly or through a chain — each one keeps the other's count above zero forever, so neither is ever deallocated, even after every external reference to the pair is gone. This is the classic delegate/closure trap:
final class ProfileViewController: UIViewController {private let imageLoader = ImageLoader()override func viewDidLoad() {super.viewDidLoad()// self is captured strongly by default inside the closure...imageLoader.onComplete = { image inself.avatarView.image = image // ...and self holds imageLoader strongly.}imageLoader.load(url: profileImageURL)}}final class ImageLoader {var onComplete: ((UIImage) -> Void)?// Holding the closure keeps whatever it captured alive for as long// as this ImageLoader instance is alive.}
ProfileViewController owns imageLoader strongly. The closure assigned to imageLoader.onComplete captures self strongly by default. So imageLoader keeps the view controller alive, and the view controller keeps the loader alive — a two-object cycle. Even after the user backs out of this screen and every navigation reference is dropped, the pair stays resident. The fix is a capture list breaking one side of the cycle, usually [weak self]:
imageLoader.onComplete = { [weak self] image inself?.avatarView.image = image}
The same pattern applies to delegate properties (weak var delegate: SomeDelegate?) and to NotificationCenter/Combine subscriptions that capture self without [weak self].
#What Instruments actually shows you
Leaks (a template in Instruments) samples the heap over time and specifically flags allocations that have become unreachable via any retain chain from a root — genuine leaks in the strict sense. It's precise but narrow: a retain cycle where both objects are still technically reachable from some root (e.g., they're both still referenced from a long-lived singleton) won't always surface as a classic "leak," which is why the more useful day-to-day tool is often the Allocations instrument's live object graph, filtered to your view controller's class name, watched across a navigate-in/navigate-out cycle. If the count of ProfileViewController instances climbs by one every time you push and pop that screen and never comes back down, you have a retain problem even if Leaks stays quiet.
The single most direct tool is the Memory Graph Debugger, built into Xcode itself (the icon that looks like three connected circles in the debug bar). Pause execution, open it, and it renders the live object graph — every allocated object and the reference (arrow) that's keeping it alive. A retain cycle shows up as a literal closed loop of purple arrows between two nodes, and clicking either object shows the exact backtrace of where that retaining reference was created. This is the tool that turns "I suspect a leak" into "here is the exact line that created the strong reference causing it," which is what you'd actually want to hand a teammate in a PR comment.
#A workflow you can describe end to end
unowned is the other tool in this box, and the interview-worthy distinction is when to reach for it instead of weak: weak is always optional and safely becomes nil once the referenced object deallocates, which is correct whenever the reference might legitimately outlive the object it captures (a delegate that could be reassigned or torn down first). unowned asserts the captured object can never be nil for the lifetime it's used — cheaper (no optional wrapping) but a crash, not a graceful nil, if that assumption is ever wrong. unowned belongs on relationships with a strictly enforced lifetime — a child object referring back to a parent guaranteed to outlive it — never as a way to silence a compiler warning without checking that guarantee.
#Why this matters beyond the leak itself
A held-but-invisible view controller isn't just wasted memory — its deinit never runs, so anything it was supposed to clean up on deallocation (observers, timers, active network tasks) keeps running against a screen the user thinks they've left, which is how you get delayed UI updates on the wrong screen, or a crash from a callback firing into a view that logically shouldn't exist anymore. This is also why deinit with a print or breakpoint is still a legitimate first-line, low-ceremony check before reaching for Instruments at all: if you navigate away and never see deinit fire, you already know you have a lifecycle problem before opening any profiler.