What is a retain cycle and how do you avoid it?

Define retain cycles in ARC terms, show the most common ways teams create them, and explain how to prevent and debug them in production code

Answer

The core idea

A retain cycle happens when two or more reference types hold strong references to each other, so ARC can never reduce their reference counts to zero. The objects are no longer reachable in a useful way, but they still stay alive because each object is keeping the other alive.

The short interview answer is: ARC frees objects when the strong reference count reaches zero. A retain cycle prevents that from ever happening.

1. Simple object-to-object cycle

This often shows up in app architecture when a view controller owns a presenter, and the presenter also strongly owns the view controller. The example below is intentionally wrong: both sides are strong, so it creates a real retain cycle.

swift
import Foundation

final class InterviewViewController {
    var presenter: InterviewPresenter?

    deinit {
        print("InterviewViewController deinit")
    }
}

final class InterviewPresenter {
    var view: InterviewViewController? // ❌ Strong capture of view controller

    deinit {
        print("InterviewPresenter deinit")
    }
}

var viewController: InterviewViewController? = InterviewViewController()
var presenter: InterviewPresenter? = InterviewPresenter()

viewController?.presenter = presenter
presenter?.view = viewController

viewController = nil
presenter = nil

After viewController = nil and presenter = nil, you might expect both objects to deallocate. They do not, because InterviewViewController still strongly owns presenter, and InterviewPresenter still strongly owns viewController. That strong-reference loop keeps both objects alive, so the deinit prints never happen.

2. Closure capture cycle

Closures are a very common interview example because the cycle is less obvious in code review. The object owns the closure property, and the closure captures the object.

swift
final class ProfileViewController: UIViewController {
    var onSave: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()

        onSave = {
            self.dismiss(animated: true) // ❌ Strong capture of self
        }
    }

    deinit {
        print("ProfileViewController deinit")
    }
}

That creates this ownership graph:

  • ProfileViewController strongly owns onSave
  • onSave strongly captures self (ProfileViewController)

So the view controller never goes away unless the closure is cleared or the capture changes.

3. How to break the cycle

You break a retain cycle by deciding which reference should not participate in ownership.

Use weak when the reference can become nil

weak is the safest default for delegates, parent links, and closure captures where the referenced object may disappear before the closure runs.

swift
final class ProfileViewController: UIViewController {
    var onSave: (() -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()

        onSave = { [weak self] in
            self?.dismiss(animated: true) // ✅ Safe
        }
    }
}

Use unowned only when lifetime is guaranteed

unowned avoids optionals, but it is only correct when you know the referenced object will still exist when the reference is used. If that assumption is wrong, you get a crash.

Good interview wording is: use weak by default; use unowned only when the ownership relationship guarantees the other object outlives this reference.

Make delegates weak

Making delegates weak prevents retain cycles and memory leaks, ensuring the delegator does not strongly hold the delegate.

swift
protocol ChildCoordinatorDelegate: AnyObject {
    func childDidFinish()
}

final class ChildCoordinator {
    weak var delegate: ChildCoordinatorDelegate?
}

4. Why it matters

Retain cycles are not just a theoretical memory-management detail. If leaked objects stay alive, they can keep doing work, holding memory, and reacting to events long after that part of the app should be gone.

Common consequences in a real app:

  • dismissed screens stay in memory, so memory usage keeps growing as the user navigates
  • timers, observers, or subscriptions keep firing after a screen disappears
  • network requests or background work continue longer than expected
  • old view controllers or presenters react to new events and create confusing bugs
  • in severe cases, the app gets slower, uses more battery, or is terminated for high memory usage

5. How to debug it

Keep debugging simple:

  1. Add a deinit print to the object that should go away. If the screen is dismissed and deinit never runs, something is still retaining it.
  2. Open Xcode's Memory Graph Debugger and inspect the object that should have been released. Follow the strong reference chain until you find the cycle, which is often a delegate, stored closure, timer, or presenter/view reference.

Interview angle

Lead with ARC: a retain cycle is a strong-reference loop between reference types. Then name the common sources: object graphs, delegates, and stored closures capturing self. Finish with the practical fix: make the non-owning edge weak when lifetime is uncertain, use unowned only when lifetime is guaranteed, and verify the change with deinit and Memory Graph.