LEARN · DEBUGGING GUIDE

Swift Retain Cycles in Closures: How to Find and Fix Them

Closures capture self strongly, creating retain cycles that leak view controllers and other objects. Here's exactly how to find them and break the cycle.

AdvancedMobile8 min read

What this usually means

Closures in Swift capture references to the objects they reference. If a closure is stored as a property on an object (e.g., a callback on a view controller), and that closure captures self (the object itself) strongly, you have a retain cycle: object -> closure -> self. Self's deinit never fires, memory is leaked. The classic pattern is a view controller with a stored closure property that references self.self inside the closure, especially in async callbacks or completion handlers. ARC cannot break the cycle because both references are strong.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Step 1: Open Xcode and run the app with the Memory Graph Debugger enabled (Debug Memory Graph button in the debug bar).
  • 2Step 2: Navigate to the screen you suspect is leaking, then go back. Watch for instances of the view controller still listed in the graph.
  • 3Step 3: Click on the leaked instance and inspect the 'Referenced by' graph. Look for a cycle: a closure property that holds a strong reference to self.
  • 4Step 4: Add a breakpoint in deinit of the suspect class. If it's never called, you have a leak.
  • 5Step 5: Use the Leaks instrument (Product > Profile > Leaks) to confirm. Perform the navigation pattern multiple times and check for leaked objects.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchXcode Memory Graph Debugger: shows object graphs and reference cycles
  • searchInstruments Leaks / Allocations: track objects that are never released
  • searchdeinit methods: put a breakpoint or print statement to observe when they fire
  • searchClosure properties in view controllers: look for stored closures (e.g., var onComplete: (() -> Void)?)
  • searchDispatchQueue.async blocks: closures passed to async operations may capture self strongly
  • searchNotificationCenter observers: closures with addObserver(forName:object:queue:using:) capture self
  • searchThird-party callback blocks: many libraries use closures that might hold strong references
  • searchUIView.animate(withDuration:animations:completion:) – these capture self and may cause cycles if self is retained
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningStrong self inside a stored closure property (e.g., self.callback = { self.doSomething() })
  • warningFailing to use [weak self] in a closure that is stored or passed to an API that retains it
  • warningUsing [unowned self] when self can be nil, causing a crash upon access
  • warningNested closures where the outer closure captures self strongly and the inner does too
  • warningCapturing a strong reference to a parent object through a chain (e.g., viewModel -> closure -> viewController -> viewModel)
  • warningRetain cycles with Timer or DispatchSource via closures that capture self
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildUse [weak self] in closure capture lists, then unwrap with guard let self = self else { return }
  • buildUse [unowned self] only when you are absolutely sure self outlives the closure (e.g., in a synchronous callback that won't be retained)
  • buildBreak the cycle by making the closure property weak or unowned if possible (e.g., weak var callback: (() -> Void)?)
  • buildConvert stored closures to delegate patterns to avoid closure retain cycles entirely
  • buildFor async operations, capture a weak reference and promote to strong inside the closure only for the duration of the work
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedCheck that deinit of the leaked object is called after the fix (add a breakpoint or print statement)
  • verifiedRun the Memory Graph Debugger after navigating away: the object should no longer appear
  • verifiedRun the Leaks instrument again and confirm zero leaked objects for that scenario
  • verifiedPerform the navigation pattern 10+ times and monitor memory usage in the Debug Navigator – it should remain stable
  • verifiedWrite a unit test that creates the object, triggers the closure, then releases it and checks for nil using weak reference
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningBlindly adding [weak self] everywhere without understanding ownership – can lead to unexpected nils and crashes
  • warningUsing [unowned self] in escaping closures that may be called after self is deallocated, causing EXC_BAD_ACCESS
  • warningForgetting to handle the nil case when using [weak self] – always guard let self = self
  • warningAssuming that weak self will fix all leaks without checking the entire capture chain (e.g., parent objects)
  • warningUsing weak references in non-escaping closures – they introduce unnecessary overhead and can cause confusion
( 07 )War story

The Profile Screen That Wouldn't Die

iOS Developer (Mid-level)Swift 5, UIKit, Alamofire (networking), SDWebImage (image caching), Xcode 14

Timeline

  1. 09:15User reports app crashes after navigating to Profile screen 5-6 times.
  2. 09:30I reproduce the crash: memory warning then termination. Check Xcode Debug Navigator – memory jumps 40MB each time Profile is opened/closed.
  3. 09:45Add a breakpoint in ProfileViewController.deinit – it's never called. Leak confirmed.
  4. 10:00Open Memory Graph Debugger after dismissing Profile – instance is still alive. Click to see retainers.
  5. 10:10Graph shows a cycle: ProfileViewController -> closure property 'onProfileLoaded' -> ProfileViewController. The closure captures self strongly.
  6. 10:15Check the code: inside viewDidLoad, we have: onProfileLoaded = { self.updateUI() } – a stored closure.
  7. 10:20Fix: add [weak self] in capture list and guard let self = self. Also check other closures in the class.
  8. 10:25Run again – deinit is called. Memory stays flat after multiple navigations. Leak fixed.

I was working on a social media app. The Profile screen fetched user data from an API and displayed it with images. After a few navigations, the app would crash with a memory warning. I noticed the memory graph showing a steady increase. I put a breakpoint in deinit – it never fired. The view controller was leaking.

Using the Memory Graph Debugger, I clicked on the leaked instance and expanded the 'Referenced by' section. The graph showed a cycle: `ProfileViewController` had a strong reference to a closure property `onProfileLoaded`, and inside that closure, `self` was captured strongly. That closure was stored as a property, so the cycle was: self -> closure -> self. ARC couldn't break it.

I fixed it by adding `[weak self]` to the closure's capture list and unwrapping with `guard let self = self else { return }`. I also checked other closures in the class – there were two more in network callbacks that also needed weak self. After the fix, deinit was called, memory usage stabilized, and the crash disappeared.

Root cause

A stored closure property on the view controller captured self strongly, creating a retain cycle that prevented deallocation.

The fix

Added [weak self] to the closure capture list and safely unwrapped self inside the closure.

The lesson

Always audit stored closures for strong captures of self. Use the Memory Graph Debugger to visualize cycles. Weak self is not always needed, but for escaping closures that are stored, it's essential.

( 08 )Understanding the Retain Cycle Mechanics

ARC in Swift counts strong references. When object A holds a strong reference to object B, and B holds a strong reference back to A, both objects have a retain count of at least 1 indefinitely. Neither can be deallocated. This is a classic retain cycle.

Closures are reference types. When a closure captures a variable, it creates a strong reference to that variable by default. If the closure is stored as a property on an object (e.g., var handler: (() -> Void)?), and the closure captures self, you get: self -> closure -> self. This is the most common cause of memory leaks in Swift UI code.

( 09 )Using the Memory Graph Debugger Effectively

The Memory Graph Debugger in Xcode is the best tool for finding retain cycles. To use it, run your app, navigate to the suspect screen, then go back. Click the 'Debug Memory Graph' button (the three intersecting circles in the debug toolbar). The view controller should appear in the list if it's leaked. Click on it to see its retainers.

Look for a red badge on the instance – that indicates a cycle. Expand the tree to see which references are strong. The cycle will show a path from self to a closure property back to self. You can also see the line of code that creates the closure by clicking the arrow icon. This pinpoints exactly where the fix is needed.

( 10 )Advanced Patterns: Escaping vs. Non-Escaping Closures

Not all closures cause retain cycles. Non-escaping closures (the default) are executed immediately and do not outlive the function they are passed to. They can safely capture self strongly because the closure is released after execution. Escaping closures (marked @escaping) can be stored and called later – these are the dangerous ones.

Common escaping closures include completion handlers for network requests, timers, animations, and notification observers. Always use [weak self] in escaping closures that capture self, unless you are certain self outlives the closure (e.g., in some synchronous operations).

( 11 )Weak vs. Unowned: When to Use Each

Weak references are always optional and become nil when the object is deallocated. Use weak when the closure's lifetime might exceed the object's lifetime. Unowned references are non-optional and assume the object will never be nil when accessed. Use unowned only when you are absolutely sure the object outlives the closure, e.g., when the closure is used synchronously and will be released before the object.

I prefer weak self 90% of the time because it's safer. Unowned self can cause a crash if the object is deallocated unexpectedly. The only case I use unowned is in some lazy property initializers where the closure is guaranteed to be called only once while the object is alive.

( 12 )Preventing Retain Cycles in Async Code

Async code (DispatchQueue, URLSession, Combine) often uses escaping closures. The pattern is: capture [weak self], then guard let self = self else { return } at the start of the closure. This ensures that if self is gone, we don't execute the code. For Combine publishers, use .sink(receiveCompletion: [weak self] { ... }, receiveValue: { ... }) and store the cancellable properly.

Another common pitfall is Timer.scheduledTimer(withTimeInterval:repeats:block:). The block captures self strongly. You must invalidate the timer and set it to nil (or use a weak reference to self). Similarly, CADisplayLink closures can cause leaks. Always clean up timers and display links in deinit or viewDidDisappear.

Frequently asked questions

Is it always necessary to use [weak self] in closures?

No. Non-escaping closures (the default) do not cause retain cycles because they are executed and released immediately. For example, the closure in map, filter, or sorted does not need weak self. Only escaping closures that are stored or passed to an API that retains them need weak self. The rule: if the closure can outlive the current scope, use weak self.

Why does [unowned self] sometimes cause a crash?

Unowned self assumes the object will never be nil when the closure executes. If the object is deallocated before the closure runs (e.g., in an async network callback that fires after the view controller is dismissed), accessing self will cause a dangling pointer and crash. Always use weak self if there's any chance the object might be gone.

How can I detect retain cycles without Xcode's memory graph?

You can add a print statement in deinit of your classes and observe if it's called. Another method is to use weak references in unit tests: create an object, assign it to a weak var, then release the strong reference, and assert that the weak var is nil. If it's not nil, there's a leak. Instruments' Leaks tool also works without the memory graph.

What's the difference between a retain cycle and a strong reference cycle?

They are the same thing. A retain cycle is a set of objects that hold strong references to each other in a cycle, preventing ARC from deallocating them. 'Strong reference cycle' is the formal name. Both terms are used interchangeably in Swift.

Can a closure cause a retain cycle without capturing self?

Yes, if the closure captures other strong references that eventually lead back to self. For example, capturing a strongly held property that itself holds a strong reference to self. Also, if a closure captures a strong reference to a parent object, and that parent holds the closure, you can have a cycle. The fix is to use weak references for all captured objects that are part of the cycle.